Repository: RSSNext/Folo Branch: dev Commit: ecf1ca57ac21 Files: 2869 Total size: 8.2 MB Directory structure: gitextract_ni4pl4rv/ ├── .agents/ │ ├── settings.local.json │ └── skills/ │ ├── desktop-release/ │ │ └── SKILL.md │ ├── installing-mobile-preview-builds/ │ │ └── SKILL.md │ ├── mobile-e2e/ │ │ └── SKILL.md │ ├── mobile-release/ │ │ └── SKILL.md │ ├── mobile-self-test/ │ │ └── SKILL.md │ └── update-deps/ │ └── SKILL.md ├── .cursorignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ ├── i18n.yml │ │ └── typo.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── actions/ │ │ ├── setup-version/ │ │ │ └── action.yml │ │ └── setup-xcode/ │ │ └── action.yml │ ├── advanced-issue-labeler.yml │ ├── copilot-instructions.md │ ├── dependabot.yaml │ ├── prompts/ │ │ └── similar_issues.prompt.yml │ ├── scripts/ │ │ └── extract-release-info.mjs │ └── workflows/ │ ├── build-android.yml │ ├── build-desktop.yml │ ├── build-ios-development.yml │ ├── build-ios.yml │ ├── build-web.yml │ ├── deploy-cloudflare-desktop.yml │ ├── deploy-cloudflare-landing.yml │ ├── deploy-cloudflare-ssr.yml │ ├── issue-labeler.yml │ ├── lint.yml │ ├── pr-title-check.yml │ ├── similar-issues.yml │ ├── sync.yaml │ ├── tag.yml │ └── translator.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.mjs ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── AGENTS.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── api/ │ └── vercel_webhook.ts ├── apps/ │ ├── cli/ │ │ ├── package.json │ │ ├── skill.md │ │ ├── src/ │ │ │ ├── args.test.ts │ │ │ ├── args.ts │ │ │ ├── browser-login.test.ts │ │ │ ├── browser-login.ts │ │ │ ├── cli.e2e.test.ts │ │ │ ├── client.ts │ │ │ ├── command.ts │ │ │ ├── commands/ │ │ │ │ ├── auth.ts │ │ │ │ ├── collection.ts │ │ │ │ ├── entry.ts │ │ │ │ ├── feed.ts │ │ │ │ ├── list.ts │ │ │ │ ├── opml.ts │ │ │ │ ├── search.ts │ │ │ │ ├── subscription.ts │ │ │ │ ├── timeline.ts │ │ │ │ └── unread.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── output.test.ts │ │ │ └── output.ts │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ └── vitest.config.ts │ ├── desktop/ │ │ ├── .env.example │ │ ├── AGENTS.md │ │ ├── build/ │ │ │ ├── appxmanifest-template.xml │ │ │ ├── dev.pfx │ │ │ ├── entitlements.mac.plist │ │ │ ├── entitlements.mas.child.plist │ │ │ └── entitlements.mas.plist │ │ ├── bump.config.ts │ │ ├── bump.hotfix.config.js │ │ ├── changelog/ │ │ │ ├── 0.1.2.md │ │ │ ├── 0.2.0.md │ │ │ ├── 0.2.1.md │ │ │ ├── 0.2.2.md │ │ │ ├── 0.2.3.md │ │ │ ├── 0.2.4.md │ │ │ ├── 0.2.5.md │ │ │ ├── 0.2.6.md │ │ │ ├── 0.2.7.md │ │ │ ├── 0.2.8.md │ │ │ ├── 0.2.9.md │ │ │ ├── 0.3.0.md │ │ │ ├── 0.3.1.md │ │ │ ├── 0.3.10.md │ │ │ ├── 0.3.11.md │ │ │ ├── 0.3.12.md │ │ │ ├── 0.3.13.md │ │ │ ├── 0.3.2.md │ │ │ ├── 0.3.3.md │ │ │ ├── 0.3.4.md │ │ │ ├── 0.3.5.md │ │ │ ├── 0.3.6.md │ │ │ ├── 0.3.7.md │ │ │ ├── 0.3.8.md │ │ │ ├── 0.3.9.md │ │ │ ├── 0.4.0.md │ │ │ ├── 0.4.1.md │ │ │ ├── 0.4.2.md │ │ │ ├── 0.4.3.md │ │ │ ├── 0.4.4.md │ │ │ ├── 0.4.5.md │ │ │ ├── 0.4.6.md │ │ │ ├── 0.4.8.md │ │ │ ├── 0.5.0.md │ │ │ ├── 0.6.0.md │ │ │ ├── 0.6.1.md │ │ │ ├── 0.6.2.md │ │ │ ├── 0.6.3.md │ │ │ ├── 0.7.0.md │ │ │ ├── 0.8.0.md │ │ │ ├── 0.9.0.md │ │ │ ├── 1.0.0.md │ │ │ ├── 1.1.0.md │ │ │ ├── 1.2.2.md │ │ │ ├── 1.2.6.md │ │ │ ├── 1.3.0.md │ │ │ ├── 1.3.1.md │ │ │ ├── 1.4.0.md │ │ │ ├── next.md │ │ │ └── next.template.md │ │ ├── configs/ │ │ │ ├── vite.electron-render.config.ts │ │ │ └── vite.render.config.ts │ │ ├── dev-only/ │ │ │ └── dev-app-update.yml │ │ ├── e2e/ │ │ │ ├── playwright.config.ts │ │ │ ├── scripts/ │ │ │ │ └── capture-ui-audit.ts │ │ │ ├── support/ │ │ │ │ ├── account.ts │ │ │ │ ├── app.ts │ │ │ │ ├── auth-bootstrap.ts │ │ │ │ ├── electron.ts │ │ │ │ └── env.ts │ │ │ └── tests/ │ │ │ ├── electron/ │ │ │ │ └── core.spec.ts │ │ │ └── web/ │ │ │ ├── core.spec.ts │ │ │ └── settings-sync.spec.ts │ │ ├── electron.vite.config.ts │ │ ├── forge.config.cts │ │ ├── layer/ │ │ │ ├── main/ │ │ │ │ ├── export.ts │ │ │ │ ├── global.d.ts │ │ │ │ ├── package.json │ │ │ │ ├── preload/ │ │ │ │ │ ├── index.d.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── src/ │ │ │ │ │ ├── @types/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── i18next.d.ts │ │ │ │ │ │ └── resources.ts │ │ │ │ │ ├── before-bootstrap.ts │ │ │ │ │ ├── bootstrap.ts │ │ │ │ │ ├── constants/ │ │ │ │ │ │ ├── app.ts │ │ │ │ │ │ └── system.ts │ │ │ │ │ ├── env.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ipc/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── services/ │ │ │ │ │ │ ├── app.ts │ │ │ │ │ │ ├── auth.ts │ │ │ │ │ │ ├── cli.ts │ │ │ │ │ │ ├── debug.ts │ │ │ │ │ │ ├── dock.ts │ │ │ │ │ │ ├── integration.ts │ │ │ │ │ │ ├── menu.ts │ │ │ │ │ │ ├── reader.ts │ │ │ │ │ │ └── setting.ts │ │ │ │ │ ├── lib/ │ │ │ │ │ │ ├── api-client.ts │ │ │ │ │ │ ├── auth-cookie-migration.ts │ │ │ │ │ │ ├── cleaner.ts │ │ │ │ │ │ ├── cli-session-sync.ts │ │ │ │ │ │ ├── dock.ts │ │ │ │ │ │ ├── download.ts │ │ │ │ │ │ ├── i18n.ts │ │ │ │ │ │ ├── proxy.test.ts │ │ │ │ │ │ ├── proxy.ts │ │ │ │ │ │ ├── router.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ ├── tray.ts │ │ │ │ │ │ ├── user.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── logger.ts │ │ │ │ │ ├── manager/ │ │ │ │ │ │ ├── app.ts │ │ │ │ │ │ ├── bootstrap.ts │ │ │ │ │ │ ├── lifecycle.ts │ │ │ │ │ │ └── window.ts │ │ │ │ │ ├── menu.ts │ │ │ │ │ ├── modules/ │ │ │ │ │ │ └── language-detection/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── shims/ │ │ │ │ │ │ └── utf-8-validate.cjs │ │ │ │ │ └── updater/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── configs.ts │ │ │ │ │ ├── follow-update-provider.ts │ │ │ │ │ ├── hot-updater.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logger.ts │ │ │ │ │ └── windows-updater.ts │ │ │ │ ├── tsconfig.json │ │ │ │ └── vitest.config.ts │ │ │ └── renderer/ │ │ │ ├── debug_proxy.html │ │ │ ├── global.d.ts │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── pwa-assets.config.ts │ │ │ ├── setup-file.ts │ │ │ ├── src/ │ │ │ │ ├── @types/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── default-resource.electron.ts │ │ │ │ │ ├── default-resource.ts │ │ │ │ │ └── i18next.d.ts │ │ │ │ ├── App.tsx │ │ │ │ ├── atoms/ │ │ │ │ │ ├── ai-summary.ts │ │ │ │ │ ├── ai-translation.ts │ │ │ │ │ ├── app.ts │ │ │ │ │ ├── context-menu.ts │ │ │ │ │ ├── corner-player.ts │ │ │ │ │ ├── debug-feature.ts │ │ │ │ │ ├── dom.ts │ │ │ │ │ ├── lang.ts │ │ │ │ │ ├── network.ts │ │ │ │ │ ├── player.ts │ │ │ │ │ ├── popover.ts │ │ │ │ │ ├── preview.ts │ │ │ │ │ ├── readability.ts │ │ │ │ │ ├── server-configs.ts │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── ai.ts │ │ │ │ │ │ ├── general.ts │ │ │ │ │ │ ├── integration.ts │ │ │ │ │ │ └── ui.ts │ │ │ │ │ ├── sidebar.ts │ │ │ │ │ ├── source-content.tsx │ │ │ │ │ ├── updater.ts │ │ │ │ │ └── user.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── common/ │ │ │ │ │ │ ├── AppErrorBoundary.tsx │ │ │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ │ │ ├── ErrorElement.tsx │ │ │ │ │ │ ├── ErrorTooltip.tsx │ │ │ │ │ │ ├── ExPromise.tsx │ │ │ │ │ │ ├── Focusable.tsx │ │ │ │ │ │ ├── Fragment.tsx │ │ │ │ │ │ ├── ImpressionTracker.tsx │ │ │ │ │ │ ├── LCPEndDetector.tsx │ │ │ │ │ │ ├── LoadMoreIndicator.tsx │ │ │ │ │ │ ├── LoadRemixAsyncComponent.tsx │ │ │ │ │ │ ├── Motion.tsx │ │ │ │ │ │ ├── NotFound.tsx │ │ │ │ │ │ ├── PoweredByFooter.tsx │ │ │ │ │ │ ├── ProviderComposer.tsx │ │ │ │ │ │ ├── ReloadPrompt.tsx │ │ │ │ │ │ ├── ShadowDOM.tsx │ │ │ │ │ │ ├── SharePanel.tsx │ │ │ │ │ │ └── withAppErrorBoundary.tsx │ │ │ │ │ ├── errors/ │ │ │ │ │ │ ├── EntryNotFound.tsx │ │ │ │ │ │ ├── FeedNotFound.tsx │ │ │ │ │ │ ├── ModalError.tsx │ │ │ │ │ │ ├── PageError.tsx │ │ │ │ │ │ ├── RSSHubError.tsx │ │ │ │ │ │ ├── enum.ts │ │ │ │ │ │ ├── helper.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── mobile/ │ │ │ │ │ │ └── button.tsx │ │ │ │ │ ├── ui/ │ │ │ │ │ │ ├── ai-summary-card/ │ │ │ │ │ │ │ ├── AISummaryCardBase.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── auto-completion/ │ │ │ │ │ │ │ ├── AutoCompletion.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── background/ │ │ │ │ │ │ │ ├── WindowUnderBlur.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── button/ │ │ │ │ │ │ │ ├── AnimatedCommandButton.tsx │ │ │ │ │ │ │ ├── CommandActionButton.tsx │ │ │ │ │ │ │ ├── CopyButton.tsx │ │ │ │ │ │ │ ├── GlassButton.tsx │ │ │ │ │ │ │ └── HeaderActionButton.tsx │ │ │ │ │ │ ├── code-highlighter/ │ │ │ │ │ │ │ ├── constants/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── shiki/ │ │ │ │ │ │ │ ├── Shiki.tsx │ │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── shared.ts │ │ │ │ │ │ │ └── shiki.module.css │ │ │ │ │ │ ├── crop/ │ │ │ │ │ │ │ └── AvatarUploadModal.tsx │ │ │ │ │ │ ├── datetime/ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── dropdown-menu/ │ │ │ │ │ │ │ └── dropdown-menu.tsx │ │ │ │ │ │ ├── fab/ │ │ │ │ │ │ │ ├── FABContainer.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── hover-preview/ │ │ │ │ │ │ │ ├── EntryPreviewCard.tsx │ │ │ │ │ │ │ ├── FeedPreviewCard.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── keyboard-recorder/ │ │ │ │ │ │ │ ├── KeyRecorder.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── markdown/ │ │ │ │ │ │ │ ├── HTML.tsx │ │ │ │ │ │ │ ├── Markdown.tsx │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── Toc.tsx │ │ │ │ │ │ │ │ ├── TocItem.tsx │ │ │ │ │ │ │ │ └── hooks.tsx │ │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ │ ├── renderers/ │ │ │ │ │ │ │ │ ├── BlockErrorBoundary.tsx │ │ │ │ │ │ │ │ ├── BlockImage.tsx │ │ │ │ │ │ │ │ ├── Heading.tsx │ │ │ │ │ │ │ │ ├── InlineImage.tsx │ │ │ │ │ │ │ │ ├── MarkdownLink.tsx │ │ │ │ │ │ │ │ ├── MarkdownP.tsx │ │ │ │ │ │ │ │ ├── ctx.tsx │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── media/ │ │ │ │ │ │ │ ├── Media.tsx │ │ │ │ │ │ │ ├── MediaContainerWidthContext.tsx │ │ │ │ │ │ │ ├── MediaContainerWidthProvider.tsx │ │ │ │ │ │ │ ├── MediaInfoRecord.tsx │ │ │ │ │ │ │ ├── MediaInfoRecordContext.tsx │ │ │ │ │ │ │ ├── MediaInfoRecordProvider.tsx │ │ │ │ │ │ │ ├── PreviewMediaContent.tsx │ │ │ │ │ │ │ ├── SwipeMedia.tsx │ │ │ │ │ │ │ ├── VideoPlayer.tsx │ │ │ │ │ │ │ ├── VolumeSlider.tsx │ │ │ │ │ │ │ └── hooks.tsx │ │ │ │ │ │ ├── modal/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ └── close.tsx │ │ │ │ │ │ │ ├── helper/ │ │ │ │ │ │ │ │ ├── useAsyncModal.tsx │ │ │ │ │ │ │ │ └── useModalStackCalculationAndEffect.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── inspire/ │ │ │ │ │ │ │ │ ├── InPeekModal.tsx │ │ │ │ │ │ │ │ └── PeekModal.tsx │ │ │ │ │ │ │ └── stacked/ │ │ │ │ │ │ │ ├── AsyncModalContent.tsx │ │ │ │ │ │ │ ├── atom.ts │ │ │ │ │ │ │ ├── bus.ts │ │ │ │ │ │ │ ├── components.tsx │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ │ ├── custom-modal.tsx │ │ │ │ │ │ │ ├── declarative-modal.tsx │ │ │ │ │ │ │ ├── helper.tsx │ │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── internal/ │ │ │ │ │ │ │ │ ├── use-animate.ts │ │ │ │ │ │ │ │ ├── use-drag.ts │ │ │ │ │ │ │ │ ├── use-select.ts │ │ │ │ │ │ │ │ └── use-subscriber.ts │ │ │ │ │ │ │ ├── modal-stack.tsx │ │ │ │ │ │ │ ├── modal.tsx │ │ │ │ │ │ │ ├── overlay.tsx │ │ │ │ │ │ │ ├── provider.tsx │ │ │ │ │ │ │ └── types.tsx │ │ │ │ │ │ ├── paper/ │ │ │ │ │ │ │ ├── Paper.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── peek-modal/ │ │ │ │ │ │ ├── EntryModalPreview.tsx │ │ │ │ │ │ ├── EntryMoreActions.tsx │ │ │ │ │ │ └── EntryToastPreview.tsx │ │ │ │ │ └── ux/ │ │ │ │ │ ├── pull-to-refresh/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── transition/ │ │ │ │ │ └── icon.tsx │ │ │ │ ├── constants/ │ │ │ │ │ ├── app.tsx │ │ │ │ │ ├── copy.ts │ │ │ │ │ ├── dom.ts │ │ │ │ │ ├── env.ts │ │ │ │ │ ├── hotkeys.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui.ts │ │ │ │ ├── env.d.ts │ │ │ │ ├── errors/ │ │ │ │ │ └── CustomSafeError.ts │ │ │ │ ├── global.d.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── biz/ │ │ │ │ │ │ ├── useAsRead.ts │ │ │ │ │ │ ├── useContextMenuActionShortCutTrigger.ts │ │ │ │ │ │ ├── useDiscoverRSSHubRoute.tsx │ │ │ │ │ │ ├── useEntryActions.tsx │ │ │ │ │ │ ├── useEntryContextMenu.ts │ │ │ │ │ │ ├── useFeature.ts │ │ │ │ │ │ ├── useFeedActions.tsx │ │ │ │ │ │ ├── useFollow.tsx │ │ │ │ │ │ ├── useNavigateEntry.ts │ │ │ │ │ │ ├── usePeekModal.tsx │ │ │ │ │ │ ├── useProxySetting.ts │ │ │ │ │ │ ├── useReduceMotion.ts │ │ │ │ │ │ ├── useRenderStyle.tsx │ │ │ │ │ │ ├── useRouteParams.ts │ │ │ │ │ │ ├── useShowEntryDetailsColumn.ts │ │ │ │ │ │ ├── useSubscriptionActions.tsx │ │ │ │ │ │ ├── useTimelineList.ts │ │ │ │ │ │ └── useTraySetting.ts │ │ │ │ │ └── common/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useBizQuery.ts │ │ │ │ │ ├── useContextMenu.tsx │ │ │ │ │ ├── useFeedSafeUrl.ts │ │ │ │ │ ├── useI18n.ts │ │ │ │ │ ├── useLoginModal.tsx │ │ │ │ │ ├── usePreventOverscrollBounce.ts │ │ │ │ │ ├── useRecaptchaToken.ts │ │ │ │ │ ├── useRequireLogin.ts │ │ │ │ │ └── useSyncTheme.ts │ │ │ │ ├── i18n.ts │ │ │ │ ├── initialize/ │ │ │ │ │ ├── analytics.ts │ │ │ │ │ ├── global-shortcuts.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── history.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── migrates/ │ │ │ │ │ │ ├── helper.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── v/ │ │ │ │ │ │ └── v1.ts │ │ │ │ │ ├── queue.ts │ │ │ │ │ └── settings.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── parse-html.test.ts │ │ │ │ │ ├── api-client.ts │ │ │ │ │ ├── app.ts │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── avatar-upload.ts │ │ │ │ │ ├── client-session.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── clipboard.ts │ │ │ │ │ ├── defineQuery.ts │ │ │ │ │ ├── dev.tsx │ │ │ │ │ ├── error-parser.ts │ │ │ │ │ ├── export.ts │ │ │ │ │ ├── features.tsx │ │ │ │ │ ├── ga4.ts │ │ │ │ │ ├── img-proxy.ts │ │ │ │ │ ├── issues.ts │ │ │ │ │ ├── jotai.ts │ │ │ │ │ ├── language.ts │ │ │ │ │ ├── load-language.ts │ │ │ │ │ ├── log.ts │ │ │ │ │ ├── native-menu.ts │ │ │ │ │ ├── observe-resize.ts │ │ │ │ │ ├── parse-html.ts │ │ │ │ │ ├── parse-markdown.ts │ │ │ │ │ ├── parsers.ts │ │ │ │ │ ├── query-client.ts │ │ │ │ │ ├── simple-text-selection.ts │ │ │ │ │ ├── url-builder.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── main.tsx │ │ │ │ ├── modules/ │ │ │ │ │ ├── action/ │ │ │ │ │ │ ├── action-setting.tsx │ │ │ │ │ │ ├── constants.tsx │ │ │ │ │ │ ├── rule-card.tsx │ │ │ │ │ │ ├── rule-summary.ts │ │ │ │ │ │ ├── then-section.tsx │ │ │ │ │ │ ├── utils.ts │ │ │ │ │ │ └── when-section.tsx │ │ │ │ │ ├── ai-chat/ │ │ │ │ │ │ ├── atoms/ │ │ │ │ │ │ │ └── session.ts │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── 3d-models/ │ │ │ │ │ │ │ │ ├── AISpline.ts │ │ │ │ │ │ │ │ └── AISplineLoader.tsx │ │ │ │ │ │ │ ├── context-bar/ │ │ │ │ │ │ │ │ ├── MentionButton.tsx │ │ │ │ │ │ │ │ ├── blocks/ │ │ │ │ │ │ │ │ │ ├── ContextBlock.tsx │ │ │ │ │ │ │ │ │ ├── TitleComponents.tsx │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ └── menus/ │ │ │ │ │ │ │ │ ├── ShortcutsMenuContent.tsx │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ ├── displays/ │ │ │ │ │ │ │ │ ├── AIChainOfThought.tsx │ │ │ │ │ │ │ │ ├── AIDisplayFlowPart.tsx │ │ │ │ │ │ │ │ ├── AIReasoningPart.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── share.tsx │ │ │ │ │ │ │ │ └── shared/ │ │ │ │ │ │ │ │ ├── AnalyticsMetrics.tsx │ │ │ │ │ │ │ │ ├── CategoryTag.tsx │ │ │ │ │ │ │ │ ├── DisplayHeader.tsx │ │ │ │ │ │ │ │ ├── EmptyState.tsx │ │ │ │ │ │ │ │ ├── FeedItemCard.tsx │ │ │ │ │ │ │ │ ├── GroupedContent.tsx │ │ │ │ │ │ │ │ ├── StatCard.tsx │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ ├── file/ │ │ │ │ │ │ │ │ └── GlobalFileDropZone.tsx │ │ │ │ │ │ │ ├── layouts/ │ │ │ │ │ │ │ │ ├── AIChatContextBar.tsx │ │ │ │ │ │ │ │ ├── AIChatRoot.tsx │ │ │ │ │ │ │ │ ├── AIChatSendButton.tsx │ │ │ │ │ │ │ │ ├── AIErrorFallback.tsx │ │ │ │ │ │ │ │ ├── AIModelIndicator.tsx │ │ │ │ │ │ │ │ ├── AISmartSidebar.css │ │ │ │ │ │ │ │ ├── AISmartSidebar.tsx │ │ │ │ │ │ │ │ ├── ChatBottomPanel.tsx │ │ │ │ │ │ │ │ ├── ChatHeader.tsx │ │ │ │ │ │ │ │ ├── ChatHistoryDropdown.tsx │ │ │ │ │ │ │ │ ├── ChatInput.tsx │ │ │ │ │ │ │ │ ├── ChatInterface.tsx │ │ │ │ │ │ │ │ ├── ChatMessageContainer.tsx │ │ │ │ │ │ │ │ ├── ChatMoreDropdown.tsx │ │ │ │ │ │ │ │ ├── ChatShortcutsRow.tsx │ │ │ │ │ │ │ │ ├── ChatTitle.tsx │ │ │ │ │ │ │ │ ├── Messages.tsx │ │ │ │ │ │ │ │ ├── RateLimitNotice.tsx │ │ │ │ │ │ │ │ ├── ScrollToBottomButton.tsx │ │ │ │ │ │ │ │ ├── TaskReportDropdown.tsx │ │ │ │ │ │ │ │ ├── WelcomeScreen.tsx │ │ │ │ │ │ │ │ └── shared/ │ │ │ │ │ │ │ │ ├── ChatSessionComponents.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── useChatSessionHandlers.tsx │ │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ │ ├── message/ │ │ │ │ │ │ │ │ ├── AIChatMessage.tsx │ │ │ │ │ │ │ │ ├── AIDataBlockPart.tsx │ │ │ │ │ │ │ │ ├── AIMarkdownMessage.tsx │ │ │ │ │ │ │ │ ├── AIMessageIdContext.tsx │ │ │ │ │ │ │ │ ├── AIMessageParts.tsx │ │ │ │ │ │ │ │ ├── BlockTitleComponents.tsx │ │ │ │ │ │ │ │ ├── EditableMessage.tsx │ │ │ │ │ │ │ │ ├── ErrorMessage.tsx │ │ │ │ │ │ │ │ ├── ImageThumbnail.tsx │ │ │ │ │ │ │ │ ├── TokenUsagePill.tsx │ │ │ │ │ │ │ │ ├── ToolInvocationComponent.tsx │ │ │ │ │ │ │ │ ├── UserChatMessage.tsx │ │ │ │ │ │ │ │ ├── UserMessageParts.tsx │ │ │ │ │ │ │ │ ├── UserRichTextMessage.tsx │ │ │ │ │ │ │ │ ├── ai-block-constants.ts │ │ │ │ │ │ │ │ ├── animated/ │ │ │ │ │ │ │ │ │ ├── AnimatedMarkdown.tsx │ │ │ │ │ │ │ │ │ ├── TokenizedText.tsx │ │ │ │ │ │ │ │ │ └── constants.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── parse-incomplete-markdown.ts │ │ │ │ │ │ │ │ └── useContextBlockPresentation.tsx │ │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ │ └── common-states.tsx │ │ │ │ │ │ │ ├── ui/ │ │ │ │ │ │ │ │ ├── AIShortcutButton.tsx │ │ │ │ │ │ │ │ ├── ShortcutTooltip.tsx │ │ │ │ │ │ │ │ └── UploadProgress.tsx │ │ │ │ │ │ │ └── welcome/ │ │ │ │ │ │ │ ├── DefaultWelcomeContent.tsx │ │ │ │ │ │ │ ├── EntrySummaryCard.tsx │ │ │ │ │ │ │ ├── EntryWelcomeContent.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── constants/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── editor/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── plugins/ │ │ │ │ │ │ │ ├── file-upload/ │ │ │ │ │ │ │ │ ├── FileAttachmentNode.tsx │ │ │ │ │ │ │ │ ├── FileUploadPlugin.tsx │ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ │ └── FileDropZone.tsx │ │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ │ ├── useFileAttachmentBlockSync.ts │ │ │ │ │ │ │ │ │ └── useFileUploadIntegration.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ │ │ └── file-handling.ts │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── mention/ │ │ │ │ │ │ │ │ ├── MentionNode.tsx │ │ │ │ │ │ │ │ ├── MentionPlugin.tsx │ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ │ ├── MentionComponent.tsx │ │ │ │ │ │ │ │ │ ├── MentionDropdown.tsx │ │ │ │ │ │ │ │ │ └── shared/ │ │ │ │ │ │ │ │ │ └── MentionTypeIcon.tsx │ │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ │ ├── dateMentionConfig.ts │ │ │ │ │ │ │ │ │ ├── dateMentionParsers.ts │ │ │ │ │ │ │ │ │ ├── dateMentionSearch.ts │ │ │ │ │ │ │ │ │ ├── dateMentionUtils.ts │ │ │ │ │ │ │ │ │ ├── useMentionKeyboard.ts │ │ │ │ │ │ │ │ │ ├── useMentionSearch.ts │ │ │ │ │ │ │ │ │ ├── useMentionSearchService.ts │ │ │ │ │ │ │ │ │ ├── useMentionSelection.ts │ │ │ │ │ │ │ │ │ └── useMentionTrigger.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ │ │ ├── mentionTextValue.ts │ │ │ │ │ │ │ │ ├── parseNaturalLanguageDate.ts │ │ │ │ │ │ │ │ ├── textReplacement.ts │ │ │ │ │ │ │ │ └── triggerDetection.ts │ │ │ │ │ │ │ ├── selection/ │ │ │ │ │ │ │ │ ├── SelectedTextNode.tsx │ │ │ │ │ │ │ │ ├── SelectedTextNodeComponent.tsx │ │ │ │ │ │ │ │ ├── SelectedTextPlugin.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── insertSelectedTextNode.ts │ │ │ │ │ │ │ │ └── selectedTextBridge.ts │ │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ │ ├── MentionLikePill.tsx │ │ │ │ │ │ │ │ │ ├── TypeaheadDropdown.tsx │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ │ ├── useListKeyboardNavigation.ts │ │ │ │ │ │ │ │ │ ├── useTextTrigger.ts │ │ │ │ │ │ │ │ │ └── useTypeaheadSelection.ts │ │ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ │ │ └── positioning.ts │ │ │ │ │ │ │ └── shortcut/ │ │ │ │ │ │ │ ├── ShortcutNode.tsx │ │ │ │ │ │ │ ├── ShortcutPlugin.tsx │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── ShortcutComponent.tsx │ │ │ │ │ │ │ │ └── ShortcutDropdown.tsx │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ ├── useShortcutKeyboard.ts │ │ │ │ │ │ │ │ ├── useShortcutSearch.ts │ │ │ │ │ │ │ │ ├── useShortcutSearchService.ts │ │ │ │ │ │ │ │ ├── useShortcutSelection.ts │ │ │ │ │ │ │ │ └── useShortcutTrigger.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── positioning.ts │ │ │ │ │ │ │ ├── shortcutTextValue.ts │ │ │ │ │ │ │ ├── textReplacement.ts │ │ │ │ │ │ │ └── triggerDetection.ts │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── useAIConfiguration.ts │ │ │ │ │ │ │ ├── useAIModel.ts │ │ │ │ │ │ │ ├── useAIShortcut.ts │ │ │ │ │ │ │ ├── useAttachScrollBeyond.tsx │ │ │ │ │ │ │ ├── useAutoScroll.tsx │ │ │ │ │ │ │ ├── useAutoTimelineSummaryShortcut.ts │ │ │ │ │ │ │ ├── useChatHistory.ts │ │ │ │ │ │ │ ├── useDisplayBlocks.ts │ │ │ │ │ │ │ ├── useFeedEntrySearchService.ts │ │ │ │ │ │ │ ├── useFileUpload.ts │ │ │ │ │ │ │ ├── useLoadMessages.ts │ │ │ │ │ │ │ ├── useMainEntryId.ts │ │ │ │ │ │ │ ├── useSendAIShortcut.ts │ │ │ │ │ │ │ └── useTimelineSummaryAutoContext.ts │ │ │ │ │ │ ├── services/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── store/ │ │ │ │ │ │ │ ├── AIChatContext.ts │ │ │ │ │ │ │ ├── chat-core/ │ │ │ │ │ │ │ │ ├── chat-actions.ts │ │ │ │ │ │ │ │ ├── chat-instance.ts │ │ │ │ │ │ │ │ ├── chat-state.ts │ │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ ├── event-system/ │ │ │ │ │ │ │ │ ├── event-emitter.ts │ │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ │ ├── slices/ │ │ │ │ │ │ │ │ ├── block.slice.ts │ │ │ │ │ │ │ │ ├── chat.slice.ts │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ │ ├── transport.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── types/ │ │ │ │ │ │ │ └── ChatSession.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── error.ts │ │ │ │ │ │ ├── extractor.ts │ │ │ │ │ │ ├── file-processing.ts │ │ │ │ │ │ ├── file-validation.ts │ │ │ │ │ │ ├── mentionDate.ts │ │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ │ ├── shortcut.ts │ │ │ │ │ │ └── titleGeneration.ts │ │ │ │ │ ├── ai-chat-session/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── query.ts │ │ │ │ │ │ ├── service.ts │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── ai-onboarding/ │ │ │ │ │ │ ├── ai-chat-pane.tsx │ │ │ │ │ │ ├── ai-onboarding-modal-content.tsx │ │ │ │ │ │ ├── feeds-selection-list.tsx │ │ │ │ │ │ ├── modal.tsx │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── ai-task/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── ai-item-actions.tsx │ │ │ │ │ │ │ ├── ai-task-modal.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── notify-channels-config.tsx │ │ │ │ │ │ │ ├── schedule-config.tsx │ │ │ │ │ │ │ ├── task-item.tsx │ │ │ │ │ │ │ └── task-list.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── query.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── app/ │ │ │ │ │ │ ├── EnvironmentIndicator.tsx │ │ │ │ │ │ ├── NetworkStatusIndicator.tsx │ │ │ │ │ │ └── Titlebar.tsx │ │ │ │ │ ├── app-layout/ │ │ │ │ │ │ ├── LAYOUT_ARCHITECTURE.md │ │ │ │ │ │ ├── MainDestopLayout.tsx │ │ │ │ │ │ ├── ai/ │ │ │ │ │ │ │ ├── AIChatFixedPanel.tsx │ │ │ │ │ │ │ ├── AIChatFloatingPanel.tsx │ │ │ │ │ │ │ └── AISplineButton.tsx │ │ │ │ │ │ ├── ai-enhanced-timeline/ │ │ │ │ │ │ │ ├── AIEnhancedTimelineLayout.tsx │ │ │ │ │ │ │ ├── MobileTimelineLayout.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── entry-content/ │ │ │ │ │ │ │ └── EntryContentPlaceholder.tsx │ │ │ │ │ │ ├── subscription-column/ │ │ │ │ │ │ │ ├── SubscriptionColumn.tsx │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ └── PodcastButton.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ └── subview/ │ │ │ │ │ │ ├── SubviewLayout.tsx │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── app-tip/ │ │ │ │ │ │ ├── AICopilotMedia.tsx │ │ │ │ │ │ ├── AppTipDialog.tsx │ │ │ │ │ │ ├── AppTipMediaPreview.tsx │ │ │ │ │ │ ├── AppTipModalContent.tsx │ │ │ │ │ │ ├── OverviewMedia.tsx │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── useNewUserGuideState.ts │ │ │ │ │ ├── auth/ │ │ │ │ │ │ ├── Form.tsx │ │ │ │ │ │ ├── LoginModalContent.tsx │ │ │ │ │ │ └── TokenModal.tsx │ │ │ │ │ ├── claim/ │ │ │ │ │ │ ├── feed-claim-modal.tsx │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── command/ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ ├── command-button.test-d.ts │ │ │ │ │ │ ├── command-button.tsx │ │ │ │ │ │ ├── command-manager.ts │ │ │ │ │ │ ├── commands/ │ │ │ │ │ │ │ ├── entry-render.tsx │ │ │ │ │ │ │ ├── entry.tsx │ │ │ │ │ │ │ ├── global.tsx │ │ │ │ │ │ │ ├── id.ts │ │ │ │ │ │ │ ├── integration.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── list.tsx │ │ │ │ │ │ │ ├── settings.tsx │ │ │ │ │ │ │ ├── subscription.tsx │ │ │ │ │ │ │ ├── timeline.tsx │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── use-command-binding.ts │ │ │ │ │ │ │ ├── use-command.test-d.ts │ │ │ │ │ │ │ ├── use-command.ts │ │ │ │ │ │ │ ├── use-register-command.test-d.ts │ │ │ │ │ │ │ ├── use-register-command.ts │ │ │ │ │ │ │ ├── use-register-hotkey.test-d.ts │ │ │ │ │ │ │ └── use-register-hotkey.ts │ │ │ │ │ │ ├── mutation-command-ids.ts │ │ │ │ │ │ ├── registry/ │ │ │ │ │ │ │ ├── command.test-d.ts │ │ │ │ │ │ │ ├── command.ts │ │ │ │ │ │ │ └── registry.ts │ │ │ │ │ │ ├── shortcuts/ │ │ │ │ │ │ │ └── SettingShortcuts.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── customize-toolbar/ │ │ │ │ │ │ ├── constant.ts │ │ │ │ │ │ ├── dnd.tsx │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ └── modal.tsx │ │ │ │ │ ├── debug/ │ │ │ │ │ │ └── registry.ts │ │ │ │ │ ├── discover/ │ │ │ │ │ │ ├── DiscoverFeedCard.tsx │ │ │ │ │ │ ├── DiscoverFeedForm.tsx │ │ │ │ │ │ ├── DiscoverForm.tsx │ │ │ │ │ │ ├── DiscoverImport.tsx │ │ │ │ │ │ ├── DiscoverInboxList.tsx │ │ │ │ │ │ ├── DiscoverTransform.tsx │ │ │ │ │ │ ├── DiscoverUser.tsx │ │ │ │ │ │ ├── DiscoveryContent.tsx │ │ │ │ │ │ ├── FeedForm.tsx │ │ │ │ │ │ ├── FeedSummary.tsx │ │ │ │ │ │ ├── Inbox/ │ │ │ │ │ │ │ ├── ConfirmDestroyModalContent.tsx │ │ │ │ │ │ │ ├── InboxActions.tsx │ │ │ │ │ │ │ ├── InboxEmail.tsx │ │ │ │ │ │ │ ├── InboxSecret.tsx │ │ │ │ │ │ │ ├── InboxTable.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── InboxForm.tsx │ │ │ │ │ │ ├── ListForm.tsx │ │ │ │ │ │ ├── OpmlAbstractGraphic.tsx │ │ │ │ │ │ ├── OpmlSelectionModal.tsx │ │ │ │ │ │ ├── RecommendationContent.tsx │ │ │ │ │ │ ├── TrendingFeedCard.tsx │ │ │ │ │ │ ├── UnifiedDiscoverForm.tsx │ │ │ │ │ │ ├── atoms/ │ │ │ │ │ │ │ └── discover.ts │ │ │ │ │ │ ├── example-data.json │ │ │ │ │ │ ├── recommendations.tsx │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── download/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── editor/ │ │ │ │ │ │ └── css-editor.tsx │ │ │ │ │ ├── entry-column/ │ │ │ │ │ │ ├── EntryColumnShortcutHandler.tsx │ │ │ │ │ │ ├── EntryItemSkeleton.tsx │ │ │ │ │ │ ├── Items/ │ │ │ │ │ │ │ ├── all-item.tsx │ │ │ │ │ │ │ ├── article-item.tsx │ │ │ │ │ │ │ ├── audio-item.tsx │ │ │ │ │ │ │ ├── getItemComponentByView.ts │ │ │ │ │ │ │ ├── getSkeletonItemComponentByView.ts │ │ │ │ │ │ │ ├── list-item.tsx │ │ │ │ │ │ │ ├── media-gallery.tsx │ │ │ │ │ │ │ ├── notification-item.tsx │ │ │ │ │ │ │ ├── picture-item-skeleton.tsx │ │ │ │ │ │ │ ├── picture-item-stateless.tsx │ │ │ │ │ │ │ ├── picture-item.tsx │ │ │ │ │ │ │ ├── picture-masonry.tsx │ │ │ │ │ │ │ ├── social-media-item.tsx │ │ │ │ │ │ │ └── video-item.tsx │ │ │ │ │ │ ├── atoms/ │ │ │ │ │ │ │ ├── ai-timeline.ts │ │ │ │ │ │ │ └── social-media-content-width.ts │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── DateItem.tsx │ │ │ │ │ │ │ ├── FooterMarkItem.tsx │ │ │ │ │ │ │ ├── VirtualRowItem.tsx │ │ │ │ │ │ │ ├── ai-timeline-loading/ │ │ │ │ │ │ │ │ ├── AITimelineLoadingOverlay.css │ │ │ │ │ │ │ │ └── AITimelineLoadingOverlay.tsx │ │ │ │ │ │ │ ├── entry-column-wrapper/ │ │ │ │ │ │ │ │ ├── EntryColumnWrapper.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ └── types.tsx │ │ │ │ │ │ │ └── mark-all-button.tsx │ │ │ │ │ │ ├── context/ │ │ │ │ │ │ │ └── EntriesContext.tsx │ │ │ │ │ │ ├── grid.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── useAttachScrollBeyond.tsx │ │ │ │ │ │ │ ├── useEntriesByView.ts │ │ │ │ │ │ │ ├── useEntryIdListSnap.ts │ │ │ │ │ │ │ ├── useEntryMarkReadHandler.tsx │ │ │ │ │ │ │ ├── useEntryVirtualization.ts │ │ │ │ │ │ │ ├── useIsPreviewFeed.ts │ │ │ │ │ │ │ ├── useLocalEntries.ts │ │ │ │ │ │ │ ├── useMarkAll.ts │ │ │ │ │ │ │ ├── useNavigateFirstEntry.tsx │ │ │ │ │ │ │ └── useWheelGestureClose.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── item-stateless.tsx │ │ │ │ │ │ ├── item.tsx │ │ │ │ │ │ ├── layouts/ │ │ │ │ │ │ │ ├── AppendTaildingDivider.tsx │ │ │ │ │ │ │ ├── EntryItemWrapper.tsx │ │ │ │ │ │ │ ├── EntryListHeader.tsx │ │ │ │ │ │ │ └── buttons/ │ │ │ │ │ │ │ └── SwitchToMasonryButton.tsx │ │ │ │ │ │ ├── list.tsx │ │ │ │ │ │ ├── star-icon.tsx │ │ │ │ │ │ ├── store/ │ │ │ │ │ │ │ └── EntryColumnContext.ts │ │ │ │ │ │ ├── styles.ts │ │ │ │ │ │ ├── templates/ │ │ │ │ │ │ │ ├── grid-item-template.tsx │ │ │ │ │ │ │ └── list-item-template.tsx │ │ │ │ │ │ ├── translation.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── entry-content/ │ │ │ │ │ │ ├── EntryContent.tsx │ │ │ │ │ │ ├── EntryContentForPreview.tsx │ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ │ ├── header-actions.tsx │ │ │ │ │ │ │ ├── more-actions.tsx │ │ │ │ │ │ │ └── picture-gallery.tsx │ │ │ │ │ │ ├── atoms.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── AISummary.tsx │ │ │ │ │ │ │ ├── ApplyEntryActions.tsx │ │ │ │ │ │ │ ├── EntryAttachments.tsx │ │ │ │ │ │ │ ├── EntryPlaceholderLogo.tsx │ │ │ │ │ │ │ ├── EntryTimelineSidebar.tsx │ │ │ │ │ │ │ ├── EntryTitle.tsx │ │ │ │ │ │ │ ├── ImageGalleryContent.tsx │ │ │ │ │ │ │ ├── SourceContentView.tsx │ │ │ │ │ │ │ ├── WarnGoToExternalLink.tsx │ │ │ │ │ │ │ ├── entry-content/ │ │ │ │ │ │ │ │ ├── EntryCommandShortcutRegister.tsx │ │ │ │ │ │ │ │ ├── EntryContentFallback.tsx │ │ │ │ │ │ │ │ ├── EntryContentLoading.tsx │ │ │ │ │ │ │ │ ├── EntryNoContent.tsx │ │ │ │ │ │ │ │ ├── EntryRenderError.tsx │ │ │ │ │ │ │ │ ├── EntryScrollingAndNavigationHandler.tsx │ │ │ │ │ │ │ │ ├── EntryTitleMetaHandler.tsx │ │ │ │ │ │ │ │ ├── ReadabilityNotice.tsx │ │ │ │ │ │ │ │ ├── accessories/ │ │ │ │ │ │ │ │ │ ├── ContainerToc.tsx │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ └── types.tsx │ │ │ │ │ │ │ ├── entry-header/ │ │ │ │ │ │ │ │ ├── AIEntryHeader.tsx │ │ │ │ │ │ │ │ ├── EntryHeader.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── internal/ │ │ │ │ │ │ │ │ │ ├── EntryHeaderActionsContainer.tsx │ │ │ │ │ │ │ │ │ ├── EntryHeaderBreadcrumb.tsx │ │ │ │ │ │ │ │ │ ├── EntryHeaderMeta.tsx │ │ │ │ │ │ │ │ │ ├── EntryHeaderReadHistory.tsx │ │ │ │ │ │ │ │ │ └── context.tsx │ │ │ │ │ │ │ │ └── types.tsx │ │ │ │ │ │ │ ├── entry-read-history/ │ │ │ │ │ │ │ │ ├── EntryReadHistory.tsx │ │ │ │ │ │ │ │ ├── EntryUser.tsx │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ ├── layouts/ │ │ │ │ │ │ │ │ ├── ArticleLayout.tsx │ │ │ │ │ │ │ │ ├── MediaLayout.tsx │ │ │ │ │ │ │ │ ├── PicturesLayout.tsx │ │ │ │ │ │ │ │ ├── SocialMediaLayout.tsx │ │ │ │ │ │ │ │ ├── VideosLayout.tsx │ │ │ │ │ │ │ │ ├── factory.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ │ │ │ │ │ ├── AuthorHeader.tsx │ │ │ │ │ │ │ │ │ ├── ContentBody.tsx │ │ │ │ │ │ │ │ │ ├── MediaTranscript.tsx │ │ │ │ │ │ │ │ │ ├── TranscriptToggle.tsx │ │ │ │ │ │ │ │ │ ├── VideoPlayer.tsx │ │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ │ └── useTranscription.ts │ │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ └── selection/ │ │ │ │ │ │ │ ├── GlassButton.tsx │ │ │ │ │ │ │ ├── SharePosterModal.tsx │ │ │ │ │ │ │ └── TextSelectionToolbar.tsx │ │ │ │ │ │ ├── constants/ │ │ │ │ │ │ │ └── navigation-hints.ts │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ └── useEntryNavigationHints.ts │ │ │ │ │ │ └── hooks.tsx │ │ │ │ │ ├── feed/ │ │ │ │ │ │ ├── feed-certification.tsx │ │ │ │ │ │ ├── feed-icon.tsx │ │ │ │ │ │ ├── feed-summary.tsx │ │ │ │ │ │ ├── feed-title.tsx │ │ │ │ │ │ └── view-select-content.tsx │ │ │ │ │ ├── integration/ │ │ │ │ │ │ ├── CustomIntegrationPreview.tsx │ │ │ │ │ │ ├── CustomIntegrationValidator.tsx │ │ │ │ │ │ ├── PlaceholderHelp.tsx │ │ │ │ │ │ ├── URLSchemePreview.tsx │ │ │ │ │ │ ├── custom-integration-manager.ts │ │ │ │ │ │ ├── fetch-adapter.ts │ │ │ │ │ │ └── url-scheme-handler.ts │ │ │ │ │ ├── modal/ │ │ │ │ │ │ ├── ConfirmDestroyModalContent.tsx │ │ │ │ │ │ ├── ShortcutModalContent.tsx │ │ │ │ │ │ └── hooks/ │ │ │ │ │ │ ├── useConfirmUnsubscribeSubscriptionModal.tsx │ │ │ │ │ │ └── useShortcutsModal.tsx │ │ │ │ │ ├── new-user-guide/ │ │ │ │ │ │ ├── ai-chat-pane.tsx │ │ │ │ │ │ ├── discover-import-step.tsx │ │ │ │ │ │ ├── feeds-selection-list.tsx │ │ │ │ │ │ ├── pre-finish.tsx │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── panel/ │ │ │ │ │ │ ├── cmdf.tsx │ │ │ │ │ │ ├── cmdk.module.css │ │ │ │ │ │ ├── cmdk.tsx │ │ │ │ │ │ └── cmdn.tsx │ │ │ │ │ ├── plan/ │ │ │ │ │ │ ├── UpgradePlanModalContent.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── player/ │ │ │ │ │ │ ├── corner-player.tsx │ │ │ │ │ │ └── entry-tts.ts │ │ │ │ │ ├── power/ │ │ │ │ │ │ ├── my-wallet-section/ │ │ │ │ │ │ │ ├── create-wallet.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── withdraw.tsx │ │ │ │ │ │ └── transaction-section/ │ │ │ │ │ │ ├── TransactionsSection.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── tx-table/ │ │ │ │ │ │ ├── TxTable.tsx │ │ │ │ │ │ ├── components.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── account-management.tsx │ │ │ │ │ │ ├── email-management.tsx │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── profile-setting-form.tsx │ │ │ │ │ │ ├── two-factor.tsx │ │ │ │ │ │ ├── update-password-form.tsx │ │ │ │ │ │ ├── user-profile-modal/ │ │ │ │ │ │ │ ├── UserProfileModalContent.tsx │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── shared.tsx │ │ │ │ │ │ └── user-profile-modal.constants.ts │ │ │ │ │ ├── renderer/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ └── TimeStamp.tsx │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ ├── html.tsx │ │ │ │ │ │ ├── markdown.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── review-prompt/ │ │ │ │ │ │ ├── ReviewPromptModalContent.tsx │ │ │ │ │ │ ├── debug.ts │ │ │ │ │ │ ├── provider.tsx │ │ │ │ │ │ ├── use-review-prompt-state.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── rsshub/ │ │ │ │ │ │ ├── add-modal-content.tsx │ │ │ │ │ │ ├── delete-modal-content.tsx │ │ │ │ │ │ └── set-modal-content.tsx │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ ├── control.tsx │ │ │ │ │ │ ├── helper/ │ │ │ │ │ │ │ ├── EnhancedIndicator.tsx │ │ │ │ │ │ │ ├── SyncIndicator.tsx │ │ │ │ │ │ │ ├── builder.ts │ │ │ │ │ │ │ ├── setting-builder.tsx │ │ │ │ │ │ │ ├── sync-queue.ts │ │ │ │ │ │ │ └── withSettingEnable.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── use-setting-ctx.ts │ │ │ │ │ │ │ └── useWrapEnhancedSettingItem.ts │ │ │ │ │ │ ├── modal/ │ │ │ │ │ │ │ ├── SettingModalContent.tsx │ │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── use-setting-modal-hack.ts │ │ │ │ │ │ │ └── useSettingModal.ts │ │ │ │ │ │ ├── section.tsx │ │ │ │ │ │ ├── sections/ │ │ │ │ │ │ │ └── fonts.tsx │ │ │ │ │ │ ├── settings-glob.ts │ │ │ │ │ │ ├── tabs/ │ │ │ │ │ │ │ ├── about.tsx │ │ │ │ │ │ │ ├── ai/ │ │ │ │ │ │ │ │ ├── PanelStyleSection.tsx │ │ │ │ │ │ │ │ ├── PersonalizePromptSection.tsx │ │ │ │ │ │ │ │ ├── TimelinePromptSection.tsx │ │ │ │ │ │ │ │ ├── byok/ │ │ │ │ │ │ │ │ │ ├── ByokProviderItem.tsx │ │ │ │ │ │ │ │ │ ├── ByokProviderModalContent.tsx │ │ │ │ │ │ │ │ │ ├── ByokSection.tsx │ │ │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── mcp/ │ │ │ │ │ │ │ │ │ ├── MCPPresetCard.tsx │ │ │ │ │ │ │ │ │ ├── MCPPresetSelectionModal.tsx │ │ │ │ │ │ │ │ │ ├── MCPServiceItem.tsx │ │ │ │ │ │ │ │ │ ├── MCPServiceModalContent.tsx │ │ │ │ │ │ │ │ │ ├── MCPServicesSection.tsx │ │ │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ │ ├── shortcuts/ │ │ │ │ │ │ │ │ │ ├── AIShortcutsSection.tsx │ │ │ │ │ │ │ │ │ ├── ShortcutItem.tsx │ │ │ │ │ │ │ │ │ ├── ShortcutModalContent.tsx │ │ │ │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ ├── tasks/ │ │ │ │ │ │ │ │ │ ├── TaskSchedulingSection.tsx │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ └── usage/ │ │ │ │ │ │ │ │ ├── UsageAnalysisSection.tsx │ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ │ ├── DetailedUsageModal.tsx │ │ │ │ │ │ │ │ │ ├── EfficiencyTab.tsx │ │ │ │ │ │ │ │ │ ├── HistoryTab.tsx │ │ │ │ │ │ │ │ │ ├── OverviewTab.tsx │ │ │ │ │ │ │ │ │ ├── PatternsTab.tsx │ │ │ │ │ │ │ │ │ ├── UsageProgressRing.tsx │ │ │ │ │ │ │ │ │ ├── UsageWarningBanner.tsx │ │ │ │ │ │ │ │ │ ├── charts/ │ │ │ │ │ │ │ │ │ │ ├── BarList.tsx │ │ │ │ │ │ │ │ │ │ ├── Sparkline.tsx │ │ │ │ │ │ │ │ │ │ ├── TinyBars.tsx │ │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ │ ├── ai.tsx │ │ │ │ │ │ │ ├── appearance.tsx │ │ │ │ │ │ │ ├── cli.tsx │ │ │ │ │ │ │ ├── data-control.tsx │ │ │ │ │ │ │ ├── feeds.tsx │ │ │ │ │ │ │ ├── general.tsx │ │ │ │ │ │ │ ├── integration/ │ │ │ │ │ │ │ │ ├── CustomIntegrationModal.tsx │ │ │ │ │ │ │ │ ├── CustomIntegrationSection.tsx │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── lists/ │ │ │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ │ └── modals.tsx │ │ │ │ │ │ │ ├── notifications.tsx │ │ │ │ │ │ │ ├── plan.tsx │ │ │ │ │ │ │ └── shortcut.tsx │ │ │ │ │ │ ├── title.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── shared/ │ │ │ │ │ │ └── ViewSelectorRadioGroup.tsx │ │ │ │ │ ├── subscription-column/ │ │ │ │ │ │ ├── CategoryRemoveDialogContent.tsx │ │ │ │ │ │ ├── CategoryUnsubscribeDialogContent.tsx │ │ │ │ │ │ ├── FeedCategory.tsx │ │ │ │ │ │ ├── FeedItem.tsx │ │ │ │ │ │ ├── RenameCategoryForm.tsx │ │ │ │ │ │ ├── SimpleDiscoverModal.tsx │ │ │ │ │ │ ├── SortedFeedItems.tsx │ │ │ │ │ │ ├── SubscriptionColumnHeader.tsx │ │ │ │ │ │ ├── SubscriptionTabButton.tsx │ │ │ │ │ │ ├── TimelineTabsSettingsModal.tsx │ │ │ │ │ │ ├── UnreadNumber.tsx │ │ │ │ │ │ ├── atom.ts │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ ├── hook.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── sort-by/ │ │ │ │ │ │ │ ├── SortByAlphabeticalList.tsx │ │ │ │ │ │ │ ├── SortByUnreadList.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── types.tsx │ │ │ │ │ │ ├── styles.ts │ │ │ │ │ │ └── subscription-list/ │ │ │ │ │ │ ├── EmptyFeedList.tsx │ │ │ │ │ │ ├── ListHeader.tsx │ │ │ │ │ │ ├── SortButton.tsx │ │ │ │ │ │ ├── StarredItem.tsx │ │ │ │ │ │ ├── SubscriptionList.tsx │ │ │ │ │ │ ├── SubscriptionListGuard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── trending/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── update-notice/ │ │ │ │ │ │ └── UpdateNotice.tsx │ │ │ │ │ ├── upgrade/ │ │ │ │ │ │ ├── container.tsx │ │ │ │ │ │ ├── lazy/ │ │ │ │ │ │ │ ├── index.electron.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── user/ │ │ │ │ │ │ ├── LoginButton.tsx │ │ │ │ │ │ ├── ProfileButton.tsx │ │ │ │ │ │ ├── UserAvatar.tsx │ │ │ │ │ │ ├── UserGallery.tsx │ │ │ │ │ │ ├── UserProBadge.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ └── wallet/ │ │ │ │ │ └── balance.tsx │ │ │ │ ├── pages/ │ │ │ │ │ ├── (main)/ │ │ │ │ │ │ ├── (layer)/ │ │ │ │ │ │ │ ├── (ai)/ │ │ │ │ │ │ │ │ └── ai/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── (subview)/ │ │ │ │ │ │ │ │ ├── action/ │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ ├── discover/ │ │ │ │ │ │ │ │ │ ├── category/ │ │ │ │ │ │ │ │ │ │ └── [category].tsx │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ ├── power/ │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ └── rsshub/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ └── timeline/ │ │ │ │ │ │ │ └── [timelineId]/ │ │ │ │ │ │ │ ├── [feedId]/ │ │ │ │ │ │ │ │ ├── [entryId]/ │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ │ ├── index.sync.tsx │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ └── settings/ │ │ │ │ │ ├── (settings)/ │ │ │ │ │ │ ├── about.tsx │ │ │ │ │ │ ├── ai.tsx │ │ │ │ │ │ ├── appearance.tsx │ │ │ │ │ │ ├── cli.tsx │ │ │ │ │ │ ├── data-control.tsx │ │ │ │ │ │ ├── feeds.tsx │ │ │ │ │ │ ├── general.tsx │ │ │ │ │ │ ├── integration.tsx │ │ │ │ │ │ ├── list.tsx │ │ │ │ │ │ ├── notifications.tsx │ │ │ │ │ │ ├── plan.tsx │ │ │ │ │ │ ├── profile.tsx │ │ │ │ │ │ └── shortcuts.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── providers/ │ │ │ │ │ ├── app-grid-layout-container-provider.tsx │ │ │ │ │ ├── context-menu-provider.tsx │ │ │ │ │ ├── extension-expose-provider.tsx │ │ │ │ │ ├── external-jump-in-provider.tsx │ │ │ │ │ ├── global-focusable-provider.tsx │ │ │ │ │ ├── global-hotkeys-provider.tsx │ │ │ │ │ ├── hotkey-provider.tsx │ │ │ │ │ ├── i18n-provider.tsx │ │ │ │ │ ├── inject-styles-provider.tsx │ │ │ │ │ ├── invalidate-query-provider.tsx │ │ │ │ │ ├── lazy/ │ │ │ │ │ │ ├── index.electron.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── main-view-hotkeys-provider.tsx │ │ │ │ │ ├── popover-provider.tsx │ │ │ │ │ ├── root-providers.tsx │ │ │ │ │ ├── server-configs-provider.tsx │ │ │ │ │ ├── setting-sync.tsx │ │ │ │ │ ├── user-provider.tsx │ │ │ │ │ └── wrapped-element-provider.tsx │ │ │ │ ├── push-notification.ts │ │ │ │ ├── queries/ │ │ │ │ │ ├── _.ts │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── discover.ts │ │ │ │ │ ├── entries.ts │ │ │ │ │ ├── feed.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mcp.ts │ │ │ │ │ ├── messaging.ts │ │ │ │ │ ├── rsshub.ts │ │ │ │ │ ├── server-configs.ts │ │ │ │ │ ├── settings.ts │ │ │ │ │ ├── types.d.ts │ │ │ │ │ ├── users.ts │ │ │ │ │ └── wallet.tsx │ │ │ │ ├── router.tsx │ │ │ │ ├── router.web.tsx │ │ │ │ ├── store/ │ │ │ │ │ ├── feed/ │ │ │ │ │ │ └── hooks.ts │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── db.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── helper.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── clear.ts │ │ │ │ │ ├── helper.test.ts │ │ │ │ │ └── helper.ts │ │ │ │ ├── styles/ │ │ │ │ │ ├── additional.css │ │ │ │ │ ├── base.css │ │ │ │ │ ├── cursor.css │ │ │ │ │ ├── font.css │ │ │ │ │ ├── main.css │ │ │ │ │ └── scrollbar.css │ │ │ │ ├── sw.ts │ │ │ │ ├── wdyr.ts │ │ │ │ └── workers/ │ │ │ │ └── sw/ │ │ │ │ ├── index.ts │ │ │ │ └── pusher.ts │ │ │ ├── tsconfig.json │ │ │ └── vitest.config.ts │ │ ├── package.json │ │ ├── plugins/ │ │ │ └── vite/ │ │ │ ├── ast.ts │ │ │ ├── cleanup.ts │ │ │ ├── compress.ts │ │ │ ├── deps.ts │ │ │ ├── generate-main-hash.ts │ │ │ ├── hmr.ts │ │ │ ├── html-inject.ts │ │ │ ├── i18n-hmr.ts │ │ │ ├── locales-json.ts │ │ │ ├── locales.ts │ │ │ ├── manifest.ts │ │ │ ├── specific-import.ts │ │ │ └── utils/ │ │ │ └── i18n-completeness.ts │ │ ├── postcss.config.cjs │ │ ├── resources/ │ │ │ ├── app-update.yml │ │ │ ├── icon-staging.icns │ │ │ └── icon.icns │ │ ├── scripts/ │ │ │ ├── apply-changelog.ts │ │ │ ├── generate-appx-manifest.ts │ │ │ ├── merge-yml.ts │ │ │ └── update-windows-yml.ts │ │ ├── static/ │ │ │ └── dmg-icon.icns │ │ ├── tailwind.config.ts │ │ ├── vite.config.electron-render.ts │ │ ├── vite.config.ts │ │ └── wrangler.jsonc │ ├── landing/ │ │ ├── .prettierrc.mjs │ │ ├── components.json │ │ ├── eslint.config.mjs │ │ ├── global.d.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── plugins/ │ │ │ └── eslint-recursive-sort.mjs │ │ ├── postcss.config.mjs │ │ ├── public/ │ │ │ ├── discover-sources.json │ │ │ └── manifest.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── ClientInit.tsx │ │ │ │ ├── InitInClient.ts │ │ │ │ ├── [locale]/ │ │ │ │ │ ├── download/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── pricing/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── privacy-policy/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── terms-of-service/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── apple-app-site-association/ │ │ │ │ │ └── route.ts │ │ │ │ ├── discover-sources/ │ │ │ │ │ └── route.ts │ │ │ │ ├── globals.css │ │ │ │ ├── init.ts │ │ │ │ ├── layout.tsx │ │ │ │ └── robots.ts │ │ │ ├── atoms/ │ │ │ │ ├── css-media.ts │ │ │ │ ├── index.ts │ │ │ │ ├── is-interactive.ts │ │ │ │ └── viewport.ts │ │ │ ├── components/ │ │ │ │ ├── brand/ │ │ │ │ │ ├── Folo.tsx │ │ │ │ │ └── Logo.tsx │ │ │ │ ├── common/ │ │ │ │ │ ├── ClientOnly.tsx │ │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ │ ├── GithubTrending.tsx │ │ │ │ │ ├── HydrationEndDetector.tsx │ │ │ │ │ ├── Lazyload.tsx │ │ │ │ │ ├── LightRays.tsx │ │ │ │ │ ├── ProviderComposer.tsx │ │ │ │ │ ├── QueryHydrate.tsx │ │ │ │ │ └── ScrollTop.tsx │ │ │ │ ├── hoc/ │ │ │ │ │ └── with-no-ssr.tsx │ │ │ │ ├── layout/ │ │ │ │ │ ├── container/ │ │ │ │ │ │ └── Normal.tsx │ │ │ │ │ ├── content/ │ │ │ │ │ │ ├── Content.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── footer/ │ │ │ │ │ │ └── Footer.tsx │ │ │ │ │ └── root/ │ │ │ │ │ └── Root.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── 3d-models/ │ │ │ │ │ │ ├── AISpline.tsx │ │ │ │ │ │ └── AISplineLoader.tsx │ │ │ │ │ ├── accordion/ │ │ │ │ │ │ └── Accordion.tsx │ │ │ │ │ ├── border-beam.tsx │ │ │ │ │ ├── button/ │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ ├── MotionButton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── checkbox/ │ │ │ │ │ │ ├── Checkbox.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── collapse/ │ │ │ │ │ │ ├── CollapseCss.tsx │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ ├── Dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── divider/ │ │ │ │ │ │ ├── Divider.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── dropdown-menu/ │ │ │ │ │ │ └── DropdownMenu.tsx │ │ │ │ │ ├── effects/ │ │ │ │ │ │ ├── GridGuides.tsx │ │ │ │ │ │ ├── ParticlesAura.tsx │ │ │ │ │ │ └── TiltCard.tsx │ │ │ │ │ ├── glass/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── highlighter.tsx │ │ │ │ │ ├── hover-card/ │ │ │ │ │ │ ├── HoverCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── input/ │ │ │ │ │ │ ├── Input.tsx │ │ │ │ │ │ ├── TextArea.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── json-highlighter/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── label/ │ │ │ │ │ │ └── Label.tsx │ │ │ │ │ ├── light-rays.tsx │ │ │ │ │ ├── loading/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── magic-card.tsx │ │ │ │ │ ├── markdown/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── modal/ │ │ │ │ │ │ ├── ModalContainer.tsx │ │ │ │ │ │ ├── ModalManager.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── panel/ │ │ │ │ │ │ └── PanelSplitter.tsx │ │ │ │ │ ├── portal/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── provider.tsx │ │ │ │ │ ├── prompts/ │ │ │ │ │ │ ├── BasePrompt.tsx │ │ │ │ │ │ ├── InputPrompt.tsx │ │ │ │ │ │ ├── Prompt.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── radio/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── relative-time/ │ │ │ │ │ │ ├── RelativeTime.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── scroll-areas/ │ │ │ │ │ │ ├── ScrollArea.tsx │ │ │ │ │ │ ├── ctx.ts │ │ │ │ │ │ └── hooks.ts │ │ │ │ │ ├── segment-tab/ │ │ │ │ │ │ ├── SegmentTab.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── select/ │ │ │ │ │ │ ├── ComboboxSelect.tsx │ │ │ │ │ │ ├── MultiSelect.tsx │ │ │ │ │ │ ├── Select.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── sheet/ │ │ │ │ │ │ ├── Sheet.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── skeleton/ │ │ │ │ │ │ ├── Skeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── switch/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── theme-switcher/ │ │ │ │ │ │ ├── ThemeSwitcher.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── tooltip/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.ts │ │ │ │ │ ├── transition/ │ │ │ │ │ │ ├── BottomToUpSoftScaleTransitionView.tsx │ │ │ │ │ │ ├── BottomToUpTransitionView.tsx │ │ │ │ │ │ ├── FadeInOutTransitionView.tsx │ │ │ │ │ │ ├── IconTransiton.tsx │ │ │ │ │ │ ├── LeftToRightTransitionView.tsx │ │ │ │ │ │ ├── RightToLeftTransitionView.tsx │ │ │ │ │ │ ├── ScaleTransitionView.tsx │ │ │ │ │ │ ├── TextUpTransitionView.tsx │ │ │ │ │ │ ├── factor.tsx │ │ │ │ │ │ └── typings.ts │ │ │ │ │ └── viewport/ │ │ │ │ │ ├── OnlyDesktop.tsx │ │ │ │ │ ├── OnlyMobile.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── widgets/ │ │ │ │ ├── download/ │ │ │ │ │ ├── DownloadHero.tsx │ │ │ │ │ └── PlatformDownloads.tsx │ │ │ │ ├── landing/ │ │ │ │ │ ├── Audience.tsx │ │ │ │ │ ├── BuiltOpen.tsx │ │ │ │ │ ├── Features.tsx │ │ │ │ │ ├── Header.tsx │ │ │ │ │ ├── Hero.tsx │ │ │ │ │ ├── PromptDemo.tsx │ │ │ │ │ ├── RepoStats.tsx │ │ │ │ │ ├── SocialProof.tsx │ │ │ │ │ ├── TrustedBy.tsx │ │ │ │ │ ├── ViewsShowcase.tsx │ │ │ │ │ └── WindowChrome.tsx │ │ │ │ ├── pricing/ │ │ │ │ │ └── PricingPlans.tsx │ │ │ │ └── simulators/ │ │ │ │ ├── EntryChatPanel.tsx │ │ │ │ ├── EntryPage.tsx │ │ │ │ ├── ListDemo.tsx │ │ │ │ ├── TimelineChatDemo.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── EntryPageOverlay.tsx │ │ │ │ │ ├── ai/ │ │ │ │ │ │ ├── AIChainOfThought.tsx │ │ │ │ │ │ ├── AIMarkdownMessage.tsx │ │ │ │ │ │ ├── AIReasoningPart.tsx │ │ │ │ │ │ ├── ToolInvocationComponent.tsx │ │ │ │ │ │ ├── animated/ │ │ │ │ │ │ │ ├── AnimatedMarkdown.tsx │ │ │ │ │ │ │ ├── TokenizedText.tsx │ │ │ │ │ │ │ └── constants.ts │ │ │ │ │ │ ├── mocks.ts │ │ │ │ │ │ ├── parse-incomplete-markdown.ts │ │ │ │ │ │ ├── reasoning-mock.json │ │ │ │ │ │ └── shiny-text/ │ │ │ │ │ │ ├── ShinyText.tsx │ │ │ │ │ │ └── index.module.css │ │ │ │ │ └── chat/ │ │ │ │ │ ├── AiMessageContextBar.tsx │ │ │ │ │ ├── AiMockMessage.tsx │ │ │ │ │ ├── AiUserMessage.tsx │ │ │ │ │ ├── ListChatPlayer.tsx │ │ │ │ │ ├── MarkdownMessage.tsx │ │ │ │ │ └── stream.ts │ │ │ │ └── mocks.tsx │ │ │ ├── constants/ │ │ │ │ ├── download.ts │ │ │ │ ├── env.ts │ │ │ │ ├── site.ts │ │ │ │ └── spring.ts │ │ │ ├── hooks/ │ │ │ │ ├── biz/ │ │ │ │ │ └── use-github-star.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── use-before-mounted.ts │ │ │ │ │ ├── use-click-away.ts │ │ │ │ │ ├── use-debounce-value.ts │ │ │ │ │ ├── use-event-callback.ts │ │ │ │ │ ├── use-input-composition.ts │ │ │ │ │ ├── use-is-active.ts │ │ │ │ │ ├── use-is-client.ts │ │ │ │ │ ├── use-is-dark.ts │ │ │ │ │ ├── use-is-mounted.ts │ │ │ │ │ ├── use-is-unmounted.ts │ │ │ │ │ ├── use-previous.ts │ │ │ │ │ ├── use-ref-value.ts │ │ │ │ │ ├── use-safe-setState.ts │ │ │ │ │ ├── use-state-ref.ts │ │ │ │ │ ├── use-sync-effect.ts │ │ │ │ │ └── useMeasure.ts │ │ │ │ └── shared/ │ │ │ │ └── use-mask-scrollarea.ts │ │ │ ├── i18n/ │ │ │ │ ├── request.ts │ │ │ │ └── routing.ts │ │ │ ├── legal/ │ │ │ │ ├── privacy.md │ │ │ │ └── tos.md │ │ │ ├── lib/ │ │ │ │ ├── apple-app-site-association.ts │ │ │ │ ├── cn.ts │ │ │ │ ├── color.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── datetime.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── env.ts │ │ │ │ ├── fonts.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── jotai.ts │ │ │ │ ├── landing-data.ts │ │ │ │ ├── noop.ts │ │ │ │ ├── platform.ts │ │ │ │ ├── pricing-data.ts │ │ │ │ ├── query-client.server.ts │ │ │ │ ├── scroller.ts │ │ │ │ ├── sleep.ts │ │ │ │ ├── spring.ts │ │ │ │ └── store.ts │ │ │ ├── messages/ │ │ │ │ ├── en.json │ │ │ │ ├── jp.json │ │ │ │ └── zh.json │ │ │ ├── providers/ │ │ │ │ ├── root/ │ │ │ │ │ ├── debug-provider.tsx │ │ │ │ │ ├── event-provider.tsx │ │ │ │ │ ├── framer-lazy-feature.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── jotai-provider.tsx │ │ │ │ │ ├── page-scroll-info-provider.tsx │ │ │ │ │ ├── react-query-provider.tsx │ │ │ │ │ └── sonner.tsx │ │ │ │ └── shared/ │ │ │ │ ├── LayoutRightSideProvider.tsx │ │ │ │ └── WrappedElementProvider.tsx │ │ │ ├── proxy.ts │ │ │ └── styles/ │ │ │ ├── globals.css │ │ │ └── pastel-theme-oklch.css │ │ ├── tsconfig.json │ │ ├── vite.config.ts │ │ ├── worker/ │ │ │ └── index.js │ │ └── wrangler.jsonc │ ├── mobile/ │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── .watchmanconfig │ │ ├── AGENTS.md │ │ ├── README.md │ │ ├── app.config.ts │ │ ├── assets/ │ │ │ └── font/ │ │ │ └── sn-pro/ │ │ │ ├── SNPro-Black.otf │ │ │ ├── SNPro-BlackItalic.otf │ │ │ ├── SNPro-Bold.otf │ │ │ ├── SNPro-BoldItalic.otf │ │ │ ├── SNPro-Book.otf │ │ │ ├── SNPro-BookItalic.otf │ │ │ ├── SNPro-Heavy.otf │ │ │ ├── SNPro-HeavyItalic.otf │ │ │ ├── SNPro-Light.otf │ │ │ ├── SNPro-LightItalic.otf │ │ │ ├── SNPro-Medium.otf │ │ │ ├── SNPro-MediumItalic.otf │ │ │ ├── SNPro-Regular.otf │ │ │ ├── SNPro-RegularItalic.otf │ │ │ ├── SNPro-Semibold.otf │ │ │ ├── SNPro-SemiboldItalic.otf │ │ │ ├── SNPro-Thin.otf │ │ │ └── SNPro-ThinItalic.otf │ │ ├── babel.config.js │ │ ├── build/ │ │ │ ├── GoogleService-Info.plist │ │ │ └── google-services.json │ │ ├── bump.config.ts │ │ ├── changelog/ │ │ │ ├── 0.1.3.md │ │ │ ├── 0.1.4.md │ │ │ ├── 0.1.5.md │ │ │ ├── 0.1.6.md │ │ │ ├── 0.1.7.md │ │ │ ├── 0.1.8.md │ │ │ ├── 0.1.9.md │ │ │ ├── 0.2.0.md │ │ │ ├── 0.2.1.md │ │ │ ├── 0.2.10.md │ │ │ ├── 0.2.2.md │ │ │ ├── 0.2.3.md │ │ │ ├── 0.2.4.md │ │ │ ├── 0.2.5.md │ │ │ ├── 0.2.6.md │ │ │ ├── 0.2.8.md │ │ │ ├── 0.3.0.md │ │ │ ├── 0.4.0.md │ │ │ ├── next.md │ │ │ └── next.template.md │ │ ├── e2e/ │ │ │ ├── README.md │ │ │ ├── flows/ │ │ │ │ ├── ios/ │ │ │ │ │ ├── auth.yaml │ │ │ │ │ ├── content.yaml │ │ │ │ │ ├── core.yaml │ │ │ │ │ ├── dismiss-overlays.yaml │ │ │ │ │ ├── ensure-onboarding-unfollowed.yaml │ │ │ │ │ ├── follow-onboarding.yaml │ │ │ │ │ ├── login.yaml │ │ │ │ │ ├── register.yaml │ │ │ │ │ ├── sign-out.yaml │ │ │ │ │ ├── timeline-entry.yaml │ │ │ │ │ └── unfollow-onboarding.yaml │ │ │ │ └── shared/ │ │ │ │ ├── core.yaml │ │ │ │ ├── dismiss-ios-system-modal.yaml │ │ │ │ ├── ensure-onboarding-unfollowed.yaml │ │ │ │ ├── follow-onboarding.yaml │ │ │ │ ├── login.yaml │ │ │ │ ├── open-auth.yaml │ │ │ │ ├── register.yaml │ │ │ │ ├── sign-out.yaml │ │ │ │ ├── timeline-entry.yaml │ │ │ │ └── unfollow-onboarding.yaml │ │ │ └── run-maestro.sh │ │ ├── eas.json │ │ ├── global.d.ts │ │ ├── ios/ │ │ │ ├── .gitignore │ │ │ ├── .xcode.env │ │ │ ├── Assets.xcassets/ │ │ │ │ ├── Contents.json │ │ │ │ ├── black_board_2_cute_fi.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── black_board_2_cute_re.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── home_5_cute_fi.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── home_5_cute_re.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── search_3_cute_fi.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── search_3_cute_re.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── settings_1_cute_fi.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ └── settings_1_cute_re.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── Folo/ │ │ │ │ ├── AppDelegate.swift │ │ │ │ ├── Folo-Bridging-Header.h │ │ │ │ ├── Folo.entitlements │ │ │ │ ├── Images.xcassets/ │ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── SplashScreenBackground.colorset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── Info.plist │ │ │ │ ├── PrivacyInfo.xcprivacy │ │ │ │ ├── SplashScreen.storyboard │ │ │ │ └── Supporting/ │ │ │ │ └── Expo.plist │ │ │ ├── Folo - Follow everything.storekit │ │ │ ├── Folo.xcodeproj/ │ │ │ │ ├── project.pbxproj │ │ │ │ └── xcshareddata/ │ │ │ │ └── xcschemes/ │ │ │ │ └── Folo.xcscheme │ │ │ ├── Folo.xcworkspace/ │ │ │ │ └── contents.xcworkspacedata │ │ │ ├── Podfile │ │ │ └── Podfile.properties.json │ │ ├── metro.config.js │ │ ├── native/ │ │ │ ├── .eslintrc.js │ │ │ ├── .gitignore │ │ │ ├── .npmignore │ │ │ ├── README.md │ │ │ ├── expo-module.config.json │ │ │ ├── ios/ │ │ │ │ ├── Controllers/ │ │ │ │ │ ├── ModalWebViewController.swift │ │ │ │ │ ├── RNSViewController.swift │ │ │ │ │ └── WebViewController.swift │ │ │ │ ├── Extensions/ │ │ │ │ │ ├── UIColor+Hex.swift │ │ │ │ │ ├── UIImage+asActivityItemSource.swift │ │ │ │ │ ├── UIImage.swift │ │ │ │ │ └── UIWindow.swift │ │ │ │ ├── FollowNative.podspec │ │ │ │ ├── Models/ │ │ │ │ │ ├── ProfileData.swift │ │ │ │ │ └── UserData.swift │ │ │ │ ├── Modules/ │ │ │ │ │ ├── AppleIntelligenceGlowEffect/ │ │ │ │ │ │ ├── AppleIntelligenceGlowEffectModule.swift │ │ │ │ │ │ ├── IntelligenceAnimationController.swift │ │ │ │ │ │ └── IntelligenceAnimationView.swift │ │ │ │ │ ├── Helper/ │ │ │ │ │ │ ├── Helper+Image.swift │ │ │ │ │ │ └── HelperModule.swift │ │ │ │ │ ├── ItemPressable/ │ │ │ │ │ │ └── ItemPressableModule.swift │ │ │ │ │ ├── PagerView/ │ │ │ │ │ │ ├── EnhancePageViewModule.swift │ │ │ │ │ │ ├── EnhancePagerController.swift │ │ │ │ │ │ └── EnhancePagerViewModule.swift │ │ │ │ │ ├── SharedWebView/ │ │ │ │ │ │ ├── FOWebView.swift │ │ │ │ │ │ ├── FollowImageURLSchemeHandler.swift │ │ │ │ │ │ ├── Injected/ │ │ │ │ │ │ │ ├── at_end.js │ │ │ │ │ │ │ └── at_start.js │ │ │ │ │ │ ├── SharedWebView+BridgeData.swift │ │ │ │ │ │ ├── SharedWebView.swift │ │ │ │ │ │ ├── SharedWebViewModule.swift │ │ │ │ │ │ ├── WebViewManager.swift │ │ │ │ │ │ └── WebViewState.swift │ │ │ │ │ ├── StoreKitTestHelper/ │ │ │ │ │ │ └── StoreKitTestHelperModule.swift │ │ │ │ │ ├── TabBar/ │ │ │ │ │ │ ├── TabBarBottomAccessoryModule.swift │ │ │ │ │ │ ├── TabBarModule.swift │ │ │ │ │ │ ├── TabBarPortalModule.swift │ │ │ │ │ │ ├── TabBarRootView.swift │ │ │ │ │ │ ├── TabScreenModule.swift │ │ │ │ │ │ └── TabScreenView.swift │ │ │ │ │ └── Toaster/ │ │ │ │ │ ├── Toast.swift │ │ │ │ │ └── ToasterModule.swift │ │ │ │ ├── Packages/ │ │ │ │ │ ├── ImageViewer_swift/ │ │ │ │ │ │ ├── ImageCarouselViewController.swift │ │ │ │ │ │ ├── ImageCarouselViewControllerProtocol.swift │ │ │ │ │ │ ├── ImageItem.swift │ │ │ │ │ │ ├── ImageLoader.swift │ │ │ │ │ │ ├── ImageViewerController.swift │ │ │ │ │ │ ├── ImageViewerOption.swift │ │ │ │ │ │ ├── ImageViewerTransitionPresentationManager.swift │ │ │ │ │ │ ├── ImageViewer_swift.h │ │ │ │ │ │ ├── LISENCE │ │ │ │ │ │ ├── SimpleImageDatasource.swift │ │ │ │ │ │ ├── UIImageView_Extensions.swift │ │ │ │ │ │ ├── UINavigationBar_Extensions.swift │ │ │ │ │ │ └── UIView_Extensions.swift │ │ │ │ │ └── SPIndicator/ │ │ │ │ │ └── LICENSE │ │ │ │ └── Utils/ │ │ │ │ └── Utils.swift │ │ │ └── package.json │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── plugins/ │ │ │ ├── android-trust-user-certs.js │ │ │ ├── network_security_config.xml │ │ │ ├── with-android-jdk-21.js │ │ │ ├── with-android-manifest-plugin.js │ │ │ ├── with-follow-app-delegate.js │ │ │ ├── with-follow-assets.js │ │ │ └── with-gradle-jvm-heap-size-increase.js │ │ ├── postcss.config.js │ │ ├── scripts/ │ │ │ ├── apply-changelog.ts │ │ │ ├── e2e-prod-ios-auth-bootstrap.ts │ │ │ └── expo-update.ts │ │ ├── shim-env.d.ts │ │ ├── src/ │ │ │ ├── @types/ │ │ │ │ ├── constants.ts │ │ │ │ ├── default-resource.ts │ │ │ │ └── i18next.d.ts │ │ │ ├── App.tsx │ │ │ ├── atoms/ │ │ │ │ ├── app.ts │ │ │ │ ├── hooks/ │ │ │ │ │ └── useDeviceType.ts │ │ │ │ ├── server-configs.ts │ │ │ │ └── settings/ │ │ │ │ ├── data.ts │ │ │ │ ├── general.ts │ │ │ │ ├── internal/ │ │ │ │ │ └── helper.ts │ │ │ │ └── ui.ts │ │ │ ├── components/ │ │ │ │ ├── common/ │ │ │ │ │ ├── AnimatedComponents.tsx │ │ │ │ │ ├── Balance.tsx │ │ │ │ │ ├── BlurEffect.tsx │ │ │ │ │ ├── CopyButton.tsx │ │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ │ ├── FullWindowOverlay.ios.tsx │ │ │ │ │ ├── FullWindowOverlay.tsx │ │ │ │ │ ├── Link.tsx │ │ │ │ │ ├── NoLoginInfo.tsx │ │ │ │ │ ├── RefreshControl.tsx │ │ │ │ │ ├── RotateableLoading.tsx │ │ │ │ │ ├── SubmitButton.tsx │ │ │ │ │ ├── SwipeableItem.tsx │ │ │ │ │ └── ThemedBlurView.tsx │ │ │ │ ├── errors/ │ │ │ │ │ ├── GlobalErrorScreen.tsx │ │ │ │ │ ├── ListErrorView.tsx │ │ │ │ │ └── ScreenErrorScreen.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── OouiUserAnonymous.tsx │ │ │ │ │ └── PhUsersBold.tsx │ │ │ │ ├── layouts/ │ │ │ │ │ ├── contexts/ │ │ │ │ │ │ └── ModalScrollViewContext.ts │ │ │ │ │ ├── header/ │ │ │ │ │ │ ├── FakeNativeHeaderTitle.tsx │ │ │ │ │ │ ├── HeaderElements.tsx │ │ │ │ │ │ ├── NavigationHeader.tsx │ │ │ │ │ │ └── hooks.ts │ │ │ │ │ ├── tabbar/ │ │ │ │ │ │ ├── BottomTabHeightProvider.tsx │ │ │ │ │ │ ├── BottomTabProvider.tsx │ │ │ │ │ │ ├── BottomTabs.tsx │ │ │ │ │ │ ├── ReactNativeTab.ios.tsx │ │ │ │ │ │ ├── ReactNativeTab.tsx │ │ │ │ │ │ ├── Tabbar.tsx │ │ │ │ │ │ ├── contexts/ │ │ │ │ │ │ │ ├── BottomTabBarBackgroundContext.tsx │ │ │ │ │ │ │ ├── BottomTabBarHeightContext.tsx │ │ │ │ │ │ │ └── BottomTabBarVisibleContext.tsx │ │ │ │ │ │ └── hooks.ts │ │ │ │ │ ├── utils/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── views/ │ │ │ │ │ ├── NavigationHeaderContext.tsx │ │ │ │ │ └── SafeNavigationScrollView.tsx │ │ │ │ ├── native/ │ │ │ │ │ ├── PagerView/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── specs.ts │ │ │ │ │ └── webview/ │ │ │ │ │ ├── DebugPanel.tsx │ │ │ │ │ ├── EntryContentWebView.tsx │ │ │ │ │ ├── atom.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── index.android.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── injected-js.ts │ │ │ │ │ ├── native-webview.android.tsx │ │ │ │ │ ├── native-webview.tsx │ │ │ │ │ └── webview-manager.ts │ │ │ │ └── ui/ │ │ │ │ ├── accordion/ │ │ │ │ │ └── AccordionItem.tsx │ │ │ │ ├── action-bar/ │ │ │ │ │ └── ActionBarItem.tsx │ │ │ │ ├── avatar/ │ │ │ │ │ └── UserAvatar.tsx │ │ │ │ ├── button/ │ │ │ │ │ └── UIBarButton.tsx │ │ │ │ ├── carousel/ │ │ │ │ │ └── MediaCarousel.tsx │ │ │ │ ├── context-menu/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── datetime/ │ │ │ │ │ └── RelativeDateTime.tsx │ │ │ │ ├── form/ │ │ │ │ │ ├── FormProvider.tsx │ │ │ │ │ ├── Label.tsx │ │ │ │ │ ├── PickerIos.tsx │ │ │ │ │ ├── Select.android.tsx │ │ │ │ │ ├── Select.tsx │ │ │ │ │ ├── Slider.tsx │ │ │ │ │ ├── Switch.tsx │ │ │ │ │ └── TextField.tsx │ │ │ │ ├── grid/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── grouped/ │ │ │ │ │ ├── GroupedInsetListCardItemStyle.tsx │ │ │ │ │ ├── GroupedList.tsx │ │ │ │ │ └── constants.ts │ │ │ │ ├── icon/ │ │ │ │ │ ├── fallback-icon.tsx │ │ │ │ │ └── feed-icon.tsx │ │ │ │ ├── image/ │ │ │ │ │ ├── Image.tsx │ │ │ │ │ ├── ImageContextMenu.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── lightbox/ │ │ │ │ │ ├── ImageViewing/ │ │ │ │ │ │ ├── @types/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── ImageDefaultHeader.tsx │ │ │ │ │ │ │ └── ImageItem/ │ │ │ │ │ │ │ ├── ImageItem.android.tsx │ │ │ │ │ │ │ ├── ImageItem.ios.tsx │ │ │ │ │ │ │ └── ImageItem.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── transforms.ts │ │ │ │ │ ├── Lightbox.tsx │ │ │ │ │ └── lightboxState.tsx │ │ │ │ ├── loading/ │ │ │ │ │ └── PlatformActivityIndicator.tsx │ │ │ │ ├── logo/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── modal/ │ │ │ │ │ ├── BottomModal.tsx │ │ │ │ │ └── imperative-modal/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── modal.tsx │ │ │ │ │ └── templates.tsx │ │ │ │ ├── overlay/ │ │ │ │ │ └── Overlay.tsx │ │ │ │ ├── pressable/ │ │ │ │ │ ├── IosItemPressable.ios.tsx │ │ │ │ │ ├── IosItemPressable.tsx │ │ │ │ │ ├── ItemPressable.ios.tsx │ │ │ │ │ ├── ItemPressable.tsx │ │ │ │ │ ├── NativePressable.ios.tsx │ │ │ │ │ ├── NativePressable.tsx │ │ │ │ │ ├── NativePressable.types.tsx │ │ │ │ │ └── enum.ts │ │ │ │ ├── qrcode/ │ │ │ │ │ ├── LICENSE │ │ │ │ │ ├── QRCode.tsx │ │ │ │ │ ├── SVGPieces.tsx │ │ │ │ │ ├── SVGRadialGradient.tsx │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useQRCodeData.ts │ │ │ │ ├── slider/ │ │ │ │ │ ├── Slider.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── switch/ │ │ │ │ │ └── Switch.tsx │ │ │ │ ├── tabview/ │ │ │ │ │ ├── TabBar.tsx │ │ │ │ │ ├── TabView.tsx │ │ │ │ │ └── types.ts │ │ │ │ ├── toast/ │ │ │ │ │ ├── CenteredToast.tsx │ │ │ │ │ ├── ToastContainer.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── ctx.tsx │ │ │ │ │ ├── manager.tsx │ │ │ │ │ └── types.ts │ │ │ │ ├── typography/ │ │ │ │ │ ├── HtmlWeb.tsx │ │ │ │ │ ├── MarkdownNative.tsx │ │ │ │ │ ├── MonoText.tsx │ │ │ │ │ └── Text.tsx │ │ │ │ └── video/ │ │ │ │ ├── PlayerAction.tsx │ │ │ │ └── VideoPlayer.tsx │ │ │ ├── constants/ │ │ │ │ ├── native-images.ts │ │ │ │ ├── spring.ts │ │ │ │ ├── ui.ts │ │ │ │ └── views.tsx │ │ │ ├── database/ │ │ │ │ └── index.ts │ │ │ ├── global.css │ │ │ ├── hooks/ │ │ │ │ ├── useBackHandler.ts │ │ │ │ ├── useDefaultHeaderHeight.ts │ │ │ │ ├── useIntentHandler.ts │ │ │ │ ├── useLoadingCallback.tsx │ │ │ │ ├── useMessaging.ts │ │ │ │ ├── useOnboarding.ts │ │ │ │ ├── useUnreadCountBadge.ts │ │ │ │ └── useWebViewNavigation.tsx │ │ │ ├── icons/ │ │ │ │ ├── AZ_sort_ascending_letters_cute_re.tsx │ │ │ │ ├── AZ_sort_descending_letters_cute_re.tsx │ │ │ │ ├── VIP_2_cute_fi.tsx │ │ │ │ ├── VIP_2_cute_re.tsx │ │ │ │ ├── add_cute_fi.tsx │ │ │ │ ├── add_cute_re.tsx │ │ │ │ ├── ai_cute_fi.tsx │ │ │ │ ├── ai_cute_re.tsx │ │ │ │ ├── alert_cute_fi.tsx │ │ │ │ ├── align_justify_cute_re.tsx │ │ │ │ ├── align_left_cute_re.tsx │ │ │ │ ├── announcement_cute_fi.tsx │ │ │ │ ├── apple_cute_fi.tsx │ │ │ │ ├── arrow_left_cute_re.tsx │ │ │ │ ├── arrow_right_circle_cute_fi.tsx │ │ │ │ ├── arrow_right_up_cute_re.tsx │ │ │ │ ├── arrow_up_circle_cute_fi.tsx │ │ │ │ ├── at_cute_re.tsx │ │ │ │ ├── attachment_cute_re.tsx │ │ │ │ ├── back_2_cute_re.tsx │ │ │ │ ├── black_board_2_cute_fi.tsx │ │ │ │ ├── black_board_2_cute_re.tsx │ │ │ │ ├── book_6_cute_re.tsx │ │ │ │ ├── bookmark_cute_re.tsx │ │ │ │ ├── bubble_cute_fi.tsx │ │ │ │ ├── bug_cute_re.tsx │ │ │ │ ├── calendar_time_add_cute_re.tsx │ │ │ │ ├── celebrate_cute_re.tsx │ │ │ │ ├── certificate_cute_fi.tsx │ │ │ │ ├── certificate_cute_re.tsx │ │ │ │ ├── check_circle_cute_re.tsx │ │ │ │ ├── check_circle_filled.tsx │ │ │ │ ├── check_cute_re.tsx │ │ │ │ ├── check_filled.tsx │ │ │ │ ├── check_line.tsx │ │ │ │ ├── classify_2_cute_re.tsx │ │ │ │ ├── close_circle_fill.tsx │ │ │ │ ├── close_cute_re.tsx │ │ │ │ ├── comment_2_cute_re.tsx │ │ │ │ ├── comment_cute_fi.tsx │ │ │ │ ├── comment_cute_li.tsx │ │ │ │ ├── comment_cute_re.tsx │ │ │ │ ├── compass_3_cute_re.tsx │ │ │ │ ├── compass_cute_fi.tsx │ │ │ │ ├── copy_2_cute_re.tsx │ │ │ │ ├── copy_cute_re.tsx │ │ │ │ ├── cursor_3_cute_re.tsx │ │ │ │ ├── danmaku_cute_fi.tsx │ │ │ │ ├── database.tsx │ │ │ │ ├── delete_2_cute_re.tsx │ │ │ │ ├── department_cute_re.tsx │ │ │ │ ├── discord_cute_fi.tsx │ │ │ │ ├── docment_cute_fi.tsx │ │ │ │ ├── docment_cute_re.tsx │ │ │ │ ├── documents_cute_re.tsx │ │ │ │ ├── download_2_cute_fi.tsx │ │ │ │ ├── download_2_cute_re.tsx │ │ │ │ ├── edit_cute_re.tsx │ │ │ │ ├── emoji_2_cute_re.tsx │ │ │ │ ├── exit_cute_fi.tsx │ │ │ │ ├── exit_cute_re.tsx │ │ │ │ ├── external_link_cute_re.tsx │ │ │ │ ├── eye_2_cute_re.tsx │ │ │ │ ├── eye_close_cute_re.tsx │ │ │ │ ├── facebook_cute_fi.tsx │ │ │ │ ├── facebook_cute_re.tsx │ │ │ │ ├── fast_forward_cute_re.tsx │ │ │ │ ├── file_import_cute_re.tsx │ │ │ │ ├── file_upload_cute_re.tsx │ │ │ │ ├── filter_cute_re.tsx │ │ │ │ ├── finger_press_cute_re.tsx │ │ │ │ ├── fire_cute_fi.tsx │ │ │ │ ├── fire_cute_re.tsx │ │ │ │ ├── flag_1_cute_fi.tsx │ │ │ │ ├── folder_open_cute_re.tsx │ │ │ │ ├── forward_2_cute_re.tsx │ │ │ │ ├── fullscreen_2_cute_re.tsx │ │ │ │ ├── fullscreen_cute_re.tsx │ │ │ │ ├── fullscreen_exit_cute_re.tsx │ │ │ │ ├── ghost_cute_re.tsx │ │ │ │ ├── gift_cute_re.tsx │ │ │ │ ├── github_2_cute_fi.tsx │ │ │ │ ├── github_cute_fi.tsx │ │ │ │ ├── google_cute_fi.tsx │ │ │ │ ├── grid_2_cute_re.tsx │ │ │ │ ├── grid_cute_re.tsx │ │ │ │ ├── hammer_cute_re.tsx │ │ │ │ ├── heart_cute_fi.tsx │ │ │ │ ├── history_cute_re.tsx │ │ │ │ ├── home_5_cute_fi.tsx │ │ │ │ ├── home_5_cute_re.tsx │ │ │ │ ├── hotkey_cute_re.tsx │ │ │ │ ├── inbox_cute_fi.tsx │ │ │ │ ├── inbox_cute_re.tsx │ │ │ │ ├── info_circle_fill.tsx │ │ │ │ ├── information_cute_re.tsx │ │ │ │ ├── instagram_cute_fi.tsx │ │ │ │ ├── key_2_cute_re.tsx │ │ │ │ ├── layout_4_cute_re.tsx │ │ │ │ ├── layout_leftbar_close_cute_re.tsx │ │ │ │ ├── layout_leftbar_open_cute_re.tsx │ │ │ │ ├── left_cute_fi.tsx │ │ │ │ ├── left_small_sharp.tsx │ │ │ │ ├── line_cute_re.tsx │ │ │ │ ├── link_cute_re.tsx │ │ │ │ ├── list_check_2_cute_re.tsx │ │ │ │ ├── list_check_3_cute_re.tsx │ │ │ │ ├── list_check_cute_re.tsx │ │ │ │ ├── list_collapse_cute_fi.tsx │ │ │ │ ├── list_collapse_cute_re.tsx │ │ │ │ ├── list_expansion_cute_fi.tsx │ │ │ │ ├── list_expansion_cute_re.tsx │ │ │ │ ├── loading_3_cute_li.tsx │ │ │ │ ├── loading_3_cute_re.tsx │ │ │ │ ├── love_cute_fi.tsx │ │ │ │ ├── love_cute_re.tsx │ │ │ │ ├── magic_2_cute_fi.tsx │ │ │ │ ├── magic_2_cute_re.tsx │ │ │ │ ├── mail_cute_re.tsx │ │ │ │ ├── mic_cute_fi.tsx │ │ │ │ ├── mic_cute_re.tsx │ │ │ │ ├── mind_map_cute_re.tsx │ │ │ │ ├── mingcute_down_line.tsx │ │ │ │ ├── mingcute_left_line.tsx │ │ │ │ ├── mingcute_right_line.tsx │ │ │ │ ├── more_1_cute_re.tsx │ │ │ │ ├── music_2_cute_fi.tsx │ │ │ │ ├── notification_cute_re.tsx │ │ │ │ ├── numbers_09_sort_ascending_cute_re.tsx │ │ │ │ ├── numbers_09_sort_descending_cute_re.tsx │ │ │ │ ├── numbers_90_sort_ascending_cute_re.tsx │ │ │ │ ├── numbers_90_sort_descending_cute_re.tsx │ │ │ │ ├── palette_cute_fi.tsx │ │ │ │ ├── palette_cute_re.tsx │ │ │ │ ├── paper_cute_fi.tsx │ │ │ │ ├── paste_cute_re.tsx │ │ │ │ ├── pause_cute_fi.tsx │ │ │ │ ├── pause_cute_re.tsx │ │ │ │ ├── pdf_cute_re.tsx │ │ │ │ ├── photo_album_cute_fi.tsx │ │ │ │ ├── photo_album_cute_re.tsx │ │ │ │ ├── pic_cute_fi.tsx │ │ │ │ ├── pic_cute_re.tsx │ │ │ │ ├── play_cute_fi.tsx │ │ │ │ ├── play_cute_re.tsx │ │ │ │ ├── plugin_2_cute_re.tsx │ │ │ │ ├── polygon_cute_re.tsx │ │ │ │ ├── power.tsx │ │ │ │ ├── power_mono.tsx │ │ │ │ ├── power_outline.tsx │ │ │ │ ├── question_cute_re.tsx │ │ │ │ ├── quill_pen_cute_re.tsx │ │ │ │ ├── rada_cute_fi.tsx │ │ │ │ ├── rada_cute_re.tsx │ │ │ │ ├── refresh_2_cute_re.tsx │ │ │ │ ├── rewind_backward_15_cute_re.tsx │ │ │ │ ├── rewind_forward_30_cute_re.tsx │ │ │ │ ├── right_cute_fi.tsx │ │ │ │ ├── right_cute_li.tsx │ │ │ │ ├── right_cute_re.tsx │ │ │ │ ├── right_small_sharp.tsx │ │ │ │ ├── rocket_cute_fi.tsx │ │ │ │ ├── rocket_cute_re.tsx │ │ │ │ ├── round_cute_fi.tsx │ │ │ │ ├── round_cute_re.tsx │ │ │ │ ├── rss_2_cute_fi.tsx │ │ │ │ ├── rss_cute_fi.tsx │ │ │ │ ├── sad_cute_re.tsx │ │ │ │ ├── safe_alert_cute_re.tsx │ │ │ │ ├── safe_lock_filled.tsx │ │ │ │ ├── safety_certificate_cute_re.tsx │ │ │ │ ├── save_cute_re.tsx │ │ │ │ ├── search_2_cute_re.tsx │ │ │ │ ├── search_3_cute_fi.tsx │ │ │ │ ├── search_3_cute_re.tsx │ │ │ │ ├── search_cute_re.tsx │ │ │ │ ├── send_plane_cute_fi.tsx │ │ │ │ ├── send_plane_cute_re.tsx │ │ │ │ ├── settings_1_cute_fi.tsx │ │ │ │ ├── settings_1_cute_re.tsx │ │ │ │ ├── settings_7_cute_re.tsx │ │ │ │ ├── share_forward_cute_re.tsx │ │ │ │ ├── shuffle_2_cute_re.tsx │ │ │ │ ├── social_x_cute_li.tsx │ │ │ │ ├── social_x_cute_re.tsx │ │ │ │ ├── sort_ascending_cute_re.tsx │ │ │ │ ├── sort_descending_cute_re.tsx │ │ │ │ ├── star_cute_fi.tsx │ │ │ │ ├── star_cute_re.tsx │ │ │ │ ├── stop_circle_cute_fi.tsx │ │ │ │ ├── telegram_cute_fi.tsx │ │ │ │ ├── telegram_cute_re.tsx │ │ │ │ ├── thought_cute_fi.tsx │ │ │ │ ├── time_cute_re.tsx │ │ │ │ ├── tool_cute_re.tsx │ │ │ │ ├── train_cute_fi.tsx │ │ │ │ ├── translate_2_ai_cute_re.tsx │ │ │ │ ├── translate_2_cute_re.tsx │ │ │ │ ├── trending_up_cute_re.tsx │ │ │ │ ├── trophy_cute_fi.tsx │ │ │ │ ├── trophy_cute_re.tsx │ │ │ │ ├── twitter_cute_fi.tsx │ │ │ │ ├── up_cute_re.tsx │ │ │ │ ├── user_3_cute_fi.tsx │ │ │ │ ├── user_3_cute_re.tsx │ │ │ │ ├── user_4_cute_fi.tsx │ │ │ │ ├── user_4_cute_re.tsx │ │ │ │ ├── user_add_2_cute_fi.tsx │ │ │ │ ├── user_heart_cute_fi.tsx │ │ │ │ ├── user_heart_cute_re.tsx │ │ │ │ ├── user_setting_cute_fi.tsx │ │ │ │ ├── user_setting_cute_re.tsx │ │ │ │ ├── video_cute_fi.tsx │ │ │ │ ├── video_cute_re.tsx │ │ │ │ ├── voice_cute_re.tsx │ │ │ │ ├── volume_cute_re.tsx │ │ │ │ ├── volume_mute_cute_re.tsx │ │ │ │ ├── volume_off_cute_re.tsx │ │ │ │ ├── wallet_2_cute_fi.tsx │ │ │ │ ├── warning_cute_re.tsx │ │ │ │ ├── web_cute_re.tsx │ │ │ │ ├── webhook_cute_re.tsx │ │ │ │ ├── weibo_cute_re.tsx │ │ │ │ ├── wifi_off_cute_re.tsx │ │ │ │ ├── world_2_cute_fi.tsx │ │ │ │ ├── world_2_cute_re.tsx │ │ │ │ └── youtube_cute_fi.tsx │ │ │ ├── initialize/ │ │ │ │ ├── analytics.ts │ │ │ │ ├── app-check.ts │ │ │ │ ├── background.ts │ │ │ │ ├── dayjs.ts │ │ │ │ ├── device.ts │ │ │ │ ├── hydrate.ts │ │ │ │ ├── index.ts │ │ │ │ ├── migration.ts │ │ │ │ └── player.ts │ │ │ ├── interfaces/ │ │ │ │ └── settings/ │ │ │ │ └── data.ts │ │ │ ├── lib/ │ │ │ │ ├── api-client.ts │ │ │ │ ├── auth-cookie-migration.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── client-session.ts │ │ │ │ ├── dialog-state.ts │ │ │ │ ├── dialog.tsx │ │ │ │ ├── e2e-config.ts │ │ │ │ ├── error-parser.ts │ │ │ │ ├── event-bus.ts │ │ │ │ ├── ga4.ts │ │ │ │ ├── i18n.ts │ │ │ │ ├── image.ts │ │ │ │ ├── img-proxy.ts │ │ │ │ ├── jotai.ts │ │ │ │ ├── kv.ts │ │ │ │ ├── loading.tsx │ │ │ │ ├── markdown.tsx │ │ │ │ ├── native/ │ │ │ │ │ ├── index.ios.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── picker.ts │ │ │ │ │ └── user-agent.ts │ │ │ │ ├── navigation/ │ │ │ │ │ ├── AttachNavigationScrollViewContext.tsx │ │ │ │ │ ├── ChainNavigationContext.tsx │ │ │ │ │ ├── GroupedNavigationRouteContext.ts │ │ │ │ │ ├── Navigation.ts │ │ │ │ │ ├── NavigationInstanceContext.ts │ │ │ │ │ ├── NavigationLink.tsx │ │ │ │ │ ├── ScreenItemContext.ts │ │ │ │ │ ├── ScreenNameContext.tsx │ │ │ │ │ ├── ScreenOptionsContext.ts │ │ │ │ │ ├── StackNavigation.tsx │ │ │ │ │ ├── StackScreenHeaderPortal.tsx │ │ │ │ │ ├── WrappedScreenItem.tsx │ │ │ │ │ ├── __internal/ │ │ │ │ │ │ └── hooks.ts │ │ │ │ │ ├── biz/ │ │ │ │ │ │ └── Destination.ts │ │ │ │ │ ├── bottom-tab/ │ │ │ │ │ │ ├── BottomTabContext.tsx │ │ │ │ │ │ ├── TabBarPortal.tsx │ │ │ │ │ │ ├── TabRoot.tsx │ │ │ │ │ │ ├── TabScreen.tsx │ │ │ │ │ │ ├── TabScreenContext.tsx │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── native.ios.tsx │ │ │ │ │ │ ├── native.tsx │ │ │ │ │ │ ├── shared.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── debug/ │ │ │ │ │ │ └── DebugButtonGroup.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── readme.md │ │ │ │ │ ├── sitemap/ │ │ │ │ │ │ └── registry.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── onboarding.ts │ │ │ │ ├── parse-api-error.ts │ │ │ │ ├── payment.ts │ │ │ │ ├── permission.ts │ │ │ │ ├── platform.ts │ │ │ │ ├── player.ts │ │ │ │ ├── proxy-env.ts │ │ │ │ ├── query-client.ts │ │ │ │ ├── responsive.ts │ │ │ │ ├── secure-store.ts │ │ │ │ ├── toast.tsx │ │ │ │ ├── token.ts │ │ │ │ ├── url-builder.ts │ │ │ │ └── volume.ts │ │ │ ├── main.tsx │ │ │ ├── modules/ │ │ │ │ ├── ai/ │ │ │ │ │ └── summary.tsx │ │ │ │ ├── context-menu/ │ │ │ │ │ ├── entry.tsx │ │ │ │ │ ├── feeds.tsx │ │ │ │ │ ├── inbox.tsx │ │ │ │ │ ├── lists.tsx │ │ │ │ │ └── video.tsx │ │ │ │ ├── debug/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── ConfirmPasswordDialog.tsx │ │ │ │ │ ├── ConfirmTOTPCodeDialog.tsx │ │ │ │ │ ├── MarkAllAsReadDialog.tsx │ │ │ │ │ └── UpgradeRequiredDialog.tsx │ │ │ │ ├── discover/ │ │ │ │ │ ├── Category.tsx │ │ │ │ │ ├── Content.tsx │ │ │ │ │ ├── DiscoverContent.tsx │ │ │ │ │ ├── FeedSummary.tsx │ │ │ │ │ ├── RecommendationListItem.tsx │ │ │ │ │ ├── Recommendations.tsx │ │ │ │ │ ├── SearchContent.tsx │ │ │ │ │ ├── SearchTabBar.tsx │ │ │ │ │ ├── Trending.tsx │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── ctx.tsx │ │ │ │ │ ├── search-tabs/ │ │ │ │ │ │ ├── SearchFeed.tsx │ │ │ │ │ │ ├── SearchFeedCard.tsx │ │ │ │ │ │ ├── SearchList.tsx │ │ │ │ │ │ ├── __base.tsx │ │ │ │ │ │ └── hooks.tsx │ │ │ │ │ └── search.tsx │ │ │ │ ├── entry-content/ │ │ │ │ │ ├── EntryAISummary.tsx │ │ │ │ │ ├── EntryContentHeaderRightActions.tsx │ │ │ │ │ ├── EntryGridFooter.tsx │ │ │ │ │ ├── EntryNavigationHeader.tsx │ │ │ │ │ ├── EntryReadHistory.tsx │ │ │ │ │ ├── EntryTitle.tsx │ │ │ │ │ ├── ctx.ts │ │ │ │ │ └── pull-up-navigation/ │ │ │ │ │ ├── PullUpIndicatorAndroid.tsx │ │ │ │ │ ├── PullUpIndicatorIos.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── use-pull-up-navigation.android.tsx │ │ │ │ │ └── use-pull-up-navigation.tsx │ │ │ │ ├── entry-list/ │ │ │ │ │ ├── EntryListContentArticle.tsx │ │ │ │ │ ├── EntryListContentPicture.tsx │ │ │ │ │ ├── EntryListContentSocial.tsx │ │ │ │ │ ├── EntryListContentVideo.tsx │ │ │ │ │ ├── EntryListContext.tsx │ │ │ │ │ ├── EntryListEmpty.tsx │ │ │ │ │ ├── EntryListFooter.tsx │ │ │ │ │ ├── EntryListSelector.tsx │ │ │ │ │ ├── ItemSeparator.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── templates/ │ │ │ │ │ │ ├── EntryNormalItem.tsx │ │ │ │ │ │ ├── EntryPictureItem.tsx │ │ │ │ │ │ ├── EntrySocialItem.tsx │ │ │ │ │ │ ├── EntryTranslation.tsx │ │ │ │ │ │ └── EntryVideoItem.tsx │ │ │ │ │ └── types.ts │ │ │ │ ├── feed/ │ │ │ │ │ ├── FollowFeed.tsx │ │ │ │ │ └── view-selector.tsx │ │ │ │ ├── list/ │ │ │ │ │ └── FollowList.tsx │ │ │ │ ├── login/ │ │ │ │ │ ├── email.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── social.tsx │ │ │ │ ├── onboarding/ │ │ │ │ │ ├── feeds-english.json │ │ │ │ │ ├── feeds.json │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── use-reading-behavior.ts │ │ │ │ │ ├── preset.ts │ │ │ │ │ ├── shared.tsx │ │ │ │ │ ├── step-finished.tsx │ │ │ │ │ ├── step-interests.tsx │ │ │ │ │ ├── step-preferences.tsx │ │ │ │ │ └── step-welcome.tsx │ │ │ │ ├── player/ │ │ │ │ │ ├── GlassPlayerTabBar.tsx │ │ │ │ │ ├── PlayerTabBar.tsx │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── control.tsx │ │ │ │ │ └── hooks.ts │ │ │ │ ├── review-prompt/ │ │ │ │ │ ├── debug.ts │ │ │ │ │ ├── provider.tsx │ │ │ │ │ ├── use-review-prompt-state.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── rsshub/ │ │ │ │ │ └── preview-url.tsx │ │ │ │ ├── screen/ │ │ │ │ │ ├── PagerList.ios.tsx │ │ │ │ │ ├── PagerList.tsx │ │ │ │ │ ├── PagerListContext.ts │ │ │ │ │ ├── TimelineSelectorList.tsx │ │ │ │ │ ├── TimelineSelectorProvider.tsx │ │ │ │ │ ├── TimelineViewSelector.tsx │ │ │ │ │ ├── TimelineViewSelectorContextMenu.tsx │ │ │ │ │ ├── action.tsx │ │ │ │ │ ├── atoms.ts │ │ │ │ │ └── hooks/ │ │ │ │ │ └── useHeaderHeight.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── SettingsList.tsx │ │ │ │ │ ├── UserHeaderBanner.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── OTPWindow.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── useShareSubscription.tsx │ │ │ │ │ │ └── useTOTPModalWrapper.tsx │ │ │ │ │ ├── routes/ │ │ │ │ │ │ ├── 2FASetting.tsx │ │ │ │ │ │ ├── About.tsx │ │ │ │ │ │ ├── Account.tsx │ │ │ │ │ │ ├── Achievement.tsx │ │ │ │ │ │ ├── Actions.tsx │ │ │ │ │ │ ├── Appearance.tsx │ │ │ │ │ │ ├── Data.tsx │ │ │ │ │ │ ├── EditCondition.tsx │ │ │ │ │ │ ├── EditProfile.tsx │ │ │ │ │ │ ├── EditRewriteRules.tsx │ │ │ │ │ │ ├── EditRule.tsx │ │ │ │ │ │ ├── EditWebhooks.tsx │ │ │ │ │ │ ├── Feeds.tsx │ │ │ │ │ │ ├── General.tsx │ │ │ │ │ │ ├── Lists.tsx │ │ │ │ │ │ ├── ManageList.tsx │ │ │ │ │ │ ├── Notifications.tsx │ │ │ │ │ │ ├── Plan.tsx │ │ │ │ │ │ ├── Privacy.tsx │ │ │ │ │ │ ├── ResetPassword.tsx │ │ │ │ │ │ └── navigateToPlanScreen.ts │ │ │ │ │ ├── sync-queue.ts │ │ │ │ │ └── utils.ts │ │ │ │ └── subscription/ │ │ │ │ ├── CategoryGrouped.tsx │ │ │ │ ├── ItemSeparator.tsx │ │ │ │ ├── SubscriptionLists.tsx │ │ │ │ ├── UnGroupedList.tsx │ │ │ │ ├── atoms.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── ctx.ts │ │ │ │ ├── header-actions.tsx │ │ │ │ └── items/ │ │ │ │ ├── InboxItem.tsx │ │ │ │ ├── ListSubscriptionItem.tsx │ │ │ │ ├── SubscriptionItem.tsx │ │ │ │ ├── UnreadCount.tsx │ │ │ │ └── types.tsx │ │ │ ├── polyfill/ │ │ │ │ ├── index.ts │ │ │ │ └── promise-with-resolvers.ts │ │ │ ├── providers/ │ │ │ │ ├── AppleIAPProvider.tsx │ │ │ │ ├── FontScalingProvider.tsx │ │ │ │ ├── ServerConfigsLoader.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── migration.tsx │ │ │ ├── screens/ │ │ │ │ ├── (headless)/ │ │ │ │ │ ├── (debug)/ │ │ │ │ │ │ ├── markdown.tsx │ │ │ │ │ │ └── text.tsx │ │ │ │ │ └── DebugScreen.tsx │ │ │ │ ├── (modal)/ │ │ │ │ │ ├── DiscoverSettingsScreen.tsx │ │ │ │ │ ├── EditEmailScreen.tsx │ │ │ │ │ ├── FollowScreen.tsx │ │ │ │ │ ├── ForgetPasswordScreen.tsx │ │ │ │ │ ├── ListScreen.tsx │ │ │ │ │ ├── LoginScreen.tsx │ │ │ │ │ ├── ProfileScreen.tsx │ │ │ │ │ ├── RsshubFormScreen.tsx │ │ │ │ │ ├── TwoFactorAuthScreen.tsx │ │ │ │ │ └── onboarding/ │ │ │ │ │ ├── EditProfileScreen.tsx │ │ │ │ │ └── SelectReadingModeScreen.tsx │ │ │ │ ├── (stack)/ │ │ │ │ │ ├── (tabs)/ │ │ │ │ │ │ ├── discover.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── settings.tsx │ │ │ │ │ │ └── subscriptions.tsx │ │ │ │ │ ├── entries/ │ │ │ │ │ │ └── [entryId]/ │ │ │ │ │ │ └── EntryDetailScreen.tsx │ │ │ │ │ ├── feeds/ │ │ │ │ │ │ └── [feedId]/ │ │ │ │ │ │ └── FeedScreen.tsx │ │ │ │ │ └── recommendation/ │ │ │ │ │ └── RecommendationCategoryScreen.tsx │ │ │ │ ├── +native-intent.tsx │ │ │ │ ├── OnboardingScreen.tsx │ │ │ │ └── PlayerScreen.tsx │ │ │ ├── sitemap.tsx │ │ │ ├── spec/ │ │ │ │ └── typography.ts │ │ │ ├── store/ │ │ │ │ └── image/ │ │ │ │ ├── hooks.ts │ │ │ │ └── store.ts │ │ │ └── theme/ │ │ │ ├── colors.ts │ │ │ ├── utils.ts │ │ │ └── web.ts │ │ ├── tailwind.config.ts │ │ ├── tailwind.dom.config.ts │ │ ├── tsconfig.json │ │ └── web-app/ │ │ ├── html-renderer/ │ │ │ ├── global.d.ts │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── postcss.config.cjs │ │ │ ├── src/ │ │ │ │ ├── App.tsx │ │ │ │ ├── HTML.tsx │ │ │ │ ├── atoms/ │ │ │ │ │ └── index.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── ProviderComposer.tsx │ │ │ │ │ └── WrappedElementProvider.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── __internal/ │ │ │ │ │ │ ├── calculateDimensions.tsx │ │ │ │ │ │ └── ctx.ts │ │ │ │ │ ├── heading.tsx │ │ │ │ │ ├── image.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── link.tsx │ │ │ │ │ ├── math.tsx │ │ │ │ │ ├── p.tsx │ │ │ │ │ └── shiki/ │ │ │ │ │ ├── Shiki.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ └── shiki.module.css │ │ │ │ ├── index.css │ │ │ │ ├── index.ts │ │ │ │ ├── managers/ │ │ │ │ │ └── webview-bridge.ts │ │ │ │ ├── parser.tsx │ │ │ │ ├── test.txt │ │ │ │ └── utils.ts │ │ │ ├── tailwind.config.ts │ │ │ ├── tsconfig.json │ │ │ ├── types/ │ │ │ │ └── index.ts │ │ │ └── vite.config.mts │ │ └── package.json │ └── ssr/ │ ├── .env.example │ ├── api/ │ │ └── index.ts │ ├── client/ │ │ ├── @types/ │ │ │ ├── constants.ts │ │ │ ├── default-resource.ts │ │ │ └── i18next.d.ts │ │ ├── App.tsx │ │ ├── atoms/ │ │ │ ├── server-configs.ts │ │ │ ├── settings/ │ │ │ │ ├── general.ts │ │ │ │ └── helper.ts │ │ │ └── user.ts │ │ ├── components/ │ │ │ ├── common/ │ │ │ │ ├── 404.tsx │ │ │ │ └── PoweredByFooter.tsx │ │ │ ├── items/ │ │ │ │ ├── grid.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── normal.tsx │ │ │ │ └── picture.tsx │ │ │ ├── layout/ │ │ │ │ └── header/ │ │ │ │ └── index.tsx │ │ │ └── ui/ │ │ │ ├── feed-certification.tsx │ │ │ ├── feed-icon.tsx │ │ │ ├── image.tsx │ │ │ └── user-avatar.tsx │ │ ├── configs.ts │ │ ├── global.d.ts │ │ ├── hooks/ │ │ │ └── useRecaptchaToken.ts │ │ ├── i18n.ts │ │ ├── index.tsx │ │ ├── initialize/ │ │ │ ├── helper.ts │ │ │ ├── index.ts │ │ │ └── sentry.ts │ │ ├── lib/ │ │ │ ├── api-fetch.ts │ │ │ ├── auth.ts │ │ │ ├── helper.ts │ │ │ ├── query-client.ts │ │ │ ├── store.ts │ │ │ └── url-builder.ts │ │ ├── modules/ │ │ │ └── login/ │ │ │ └── index.tsx │ │ ├── pages/ │ │ │ ├── (login)/ │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── login/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── metadata.ts │ │ │ │ ├── register.tsx │ │ │ │ └── reset-password.tsx │ │ │ ├── (main)/ │ │ │ │ ├── index.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── share/ │ │ │ │ ├── feeds/ │ │ │ │ │ └── [id]/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── metadata.ts │ │ │ │ ├── lists/ │ │ │ │ │ └── [id]/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── metadata.ts │ │ │ │ └── users/ │ │ │ │ └── [id]/ │ │ │ │ ├── index.tsx │ │ │ │ └── metadata.ts │ │ │ └── layout.tsx │ │ ├── providers/ │ │ │ ├── root-providers.tsx │ │ │ ├── server-configs-provider.tsx │ │ │ └── user-provider.tsx │ │ ├── query/ │ │ │ ├── auth.ts │ │ │ ├── entries.ts │ │ │ ├── feed.ts │ │ │ ├── list.ts │ │ │ └── users.ts │ │ ├── router.tsx │ │ └── styles/ │ │ └── index.css │ ├── global.ts │ ├── helper/ │ │ └── meta-map.ts │ ├── index.html │ ├── index.ts │ ├── note.md │ ├── package.json │ ├── postcss.config.cjs │ ├── public/ │ │ └── manifest.json │ ├── scripts/ │ │ ├── check-fonts.ts │ │ ├── cleanup-vercel-build.ts │ │ ├── generate-font-data.ts │ │ ├── patch-worker-build.ts │ │ ├── prepare-vercel-build.ts │ │ ├── skip-ssr-app-vercel-build.sh │ │ └── upload-fonts-to-r2.ts │ ├── src/ │ │ ├── global.d.ts │ │ ├── lib/ │ │ │ ├── api-client.ts │ │ │ ├── dev-vite.ts │ │ │ ├── load-env.ts │ │ │ ├── load-env.worker.ts │ │ │ ├── not-found.ts │ │ │ ├── og/ │ │ │ │ ├── fonts.ts │ │ │ │ ├── fonts.worker.ts │ │ │ │ ├── render-to-image.ts │ │ │ │ ├── render-to-image.worker.ts │ │ │ │ └── resvg-wasm-shim.ts │ │ │ ├── seo.ts │ │ │ └── worker-request-context.ts │ │ ├── meta-handler.map.ts │ │ ├── meta-handler.ts │ │ └── router/ │ │ ├── global.ts │ │ └── og/ │ │ ├── __base.tsx │ │ ├── feed.tsx │ │ ├── index.ts │ │ ├── list.tsx │ │ └── user.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── tsdown.config.ts │ ├── tsdown.worker.config.ts │ ├── vercel.json │ ├── vite.config.mts │ ├── worker-app.ts │ ├── worker-entry.ts │ └── wrangler.jsonc ├── changelogithub.config.ts ├── conductor.json ├── eslint.config.mjs ├── locales/ │ ├── ai/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── app/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── common/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── errors/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── external/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── lang/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── mobile/ │ │ └── default/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── native/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── settings/ │ │ ├── en.json │ │ ├── fr-FR.json │ │ ├── ja.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ └── shortcuts/ │ ├── en.json │ ├── fr-FR.json │ ├── ja.json │ ├── zh-CN.json │ └── zh-TW.json ├── package.json ├── packages/ │ ├── configs/ │ │ ├── package.json │ │ ├── tailwindcss/ │ │ │ ├── ratio-mixing-plugin.js │ │ │ ├── tailwind-extend.css │ │ │ ├── tw-css-plugin.js │ │ │ └── web.ts │ │ └── tsconfig.extend.json │ ├── internal/ │ │ ├── AGENTS.md │ │ ├── atoms/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── atoms/ │ │ │ │ │ └── user.ts │ │ │ │ └── helper/ │ │ │ │ └── setting.ts │ │ │ └── tsconfig.json │ │ ├── components/ │ │ │ ├── assets/ │ │ │ │ ├── colors-media.css │ │ │ │ ├── colors.css │ │ │ │ ├── font.css │ │ │ │ ├── index.css │ │ │ │ └── tailwind.css │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── atoms/ │ │ │ │ │ ├── mouse.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── viewport.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── Focusable/ │ │ │ │ │ │ ├── Focusable.tsx │ │ │ │ │ │ ├── GlobalFocusableProvider.tsx │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── Fragment.ts │ │ │ │ │ ├── MemoedDangerousHTMLStyle.tsx │ │ │ │ │ ├── MotionProvider.tsx │ │ │ │ │ └── ReparentPortal.tsx │ │ │ │ ├── constants/ │ │ │ │ │ └── spring.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useMedia.ts │ │ │ │ │ ├── useMobile.ts │ │ │ │ │ ├── useMouse.ts │ │ │ │ │ └── useViewport.ts │ │ │ │ ├── icons/ │ │ │ │ │ ├── Database.tsx │ │ │ │ │ ├── Meditation.tsx │ │ │ │ │ ├── MynauiInboxArchive.tsx │ │ │ │ │ ├── OouiUserAnonymous.tsx │ │ │ │ │ ├── PhCloudCheck.tsx │ │ │ │ │ ├── PhCloudWarning.tsx │ │ │ │ │ ├── PhCloudX.tsx │ │ │ │ │ ├── Progress.tsx │ │ │ │ │ ├── empty.tsx │ │ │ │ │ ├── follow.tsx │ │ │ │ │ ├── folo.tsx │ │ │ │ │ ├── infinify.tsx │ │ │ │ │ ├── logo.tsx │ │ │ │ │ ├── nft.tsx │ │ │ │ │ ├── resize.tsx │ │ │ │ │ ├── user.tsx │ │ │ │ │ └── users.tsx │ │ │ │ ├── providers/ │ │ │ │ │ ├── event-provider.tsx │ │ │ │ │ └── stable-router-provider.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── auto-resize-height/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── avatar/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── avatar-group/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── button/ │ │ │ │ │ │ ├── action-button.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── interface.ts │ │ │ │ │ │ └── variants.tsx │ │ │ │ │ ├── card/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── checkbox/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── collapse/ │ │ │ │ │ │ ├── Collapse.tsx │ │ │ │ │ │ ├── CollapseCss.tsx │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── context-menu/ │ │ │ │ │ │ ├── context-menu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── datetime/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── utils.tsx │ │ │ │ │ ├── divider/ │ │ │ │ │ │ ├── Divider.tsx │ │ │ │ │ │ ├── PanelSplitter.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── drop-zone/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── effect/ │ │ │ │ │ │ └── MagneticHoverEffect.tsx │ │ │ │ │ ├── form/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── hover-card/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── icon/ │ │ │ │ │ │ └── SiteIcon.tsx │ │ │ │ │ ├── input/ │ │ │ │ │ │ ├── DateTimePicker.tsx │ │ │ │ │ │ ├── Input.tsx │ │ │ │ │ │ ├── InputV2.tsx │ │ │ │ │ │ ├── OTP.tsx │ │ │ │ │ │ ├── TextArea.tsx │ │ │ │ │ │ ├── TextAreaWrapper.tsx │ │ │ │ │ │ ├── TimeSelect.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── json-highlighter/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── katex/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── lazy.tsx │ │ │ │ │ ├── kbd/ │ │ │ │ │ │ └── Kbd.tsx │ │ │ │ │ ├── key-value-editor/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── label/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── lexical-rich-editor/ │ │ │ │ │ │ ├── LexicalRichEditor.tsx │ │ │ │ │ │ ├── LexicalRichEditorTextArea.tsx │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── nodes.ts │ │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ │ ├── code-highlighting/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── exit-code/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── keyboard/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── string-length-change/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ └── triple-backtick-toggle/ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── theme.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── link/ │ │ │ │ │ │ ├── LinkWithTooltip.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── loading/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── markdown/ │ │ │ │ │ │ └── html.tsx │ │ │ │ │ ├── marquee/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── masonry/ │ │ │ │ │ │ ├── contexts.tsx │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── navigation-menu/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── style.ts │ │ │ │ │ ├── platform-icon/ │ │ │ │ │ │ ├── collections/ │ │ │ │ │ │ │ ├── cubox.tsx │ │ │ │ │ │ │ ├── eagle.tsx │ │ │ │ │ │ │ ├── instapaper.tsx │ │ │ │ │ │ │ ├── obsidian.tsx │ │ │ │ │ │ │ ├── outline.tsx │ │ │ │ │ │ │ ├── readeck.tsx │ │ │ │ │ │ │ ├── readwise.tsx │ │ │ │ │ │ │ ├── rss3.tsx │ │ │ │ │ │ │ ├── rsshub.tsx │ │ │ │ │ │ │ └── zotero.tsx │ │ │ │ │ │ ├── icons.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── utils.tsx │ │ │ │ │ ├── popover/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── portal/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── provider.tsx │ │ │ │ │ ├── progress/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── progressive-blur/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── radio-group/ │ │ │ │ │ │ ├── RadioCard.tsx │ │ │ │ │ │ ├── RadioGroup.tsx │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── motion.tsx │ │ │ │ │ ├── scroll-area/ │ │ │ │ │ │ ├── ScrollArea.tsx │ │ │ │ │ │ ├── ctx.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── index.module.css │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── segment/ │ │ │ │ │ │ ├── ctx.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── select/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── responsive.tsx │ │ │ │ │ ├── sheet/ │ │ │ │ │ │ ├── Sheet.tsx │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── shiny-text/ │ │ │ │ │ │ ├── ShinyText.tsx │ │ │ │ │ │ └── index.module.css │ │ │ │ │ ├── shrinking-focus-border/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── skeleton/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── slider/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── switch/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── table/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── variants.tsx │ │ │ │ │ ├── tabs/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── toast/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.ts │ │ │ │ │ ├── tooltip/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.ts │ │ │ │ │ ├── typography/ │ │ │ │ │ │ ├── EllipsisWithTooltip.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── z-index/ │ │ │ │ │ ├── ctx.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── utils/ │ │ │ │ ├── dayjs.ts │ │ │ │ ├── icon.ts │ │ │ │ ├── parse-markdown.tsx │ │ │ │ └── selector.tsx │ │ │ └── tsconfig.json │ │ ├── constants/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── app.ts │ │ │ │ ├── auth-providers.ts │ │ │ │ ├── enums.ts │ │ │ │ ├── index.ts │ │ │ │ ├── rsshub.ts │ │ │ │ ├── social.ts │ │ │ │ └── tabs.tsx │ │ │ └── tsconfig.json │ │ ├── database/ │ │ │ ├── drizzle.config.ts │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── DatabaseSource.js │ │ │ │ ├── ResourceLock.ts │ │ │ │ ├── constant.ts │ │ │ │ ├── db.desktop.ts │ │ │ │ ├── db.rn.ts │ │ │ │ ├── db.ts │ │ │ │ ├── drizzle/ │ │ │ │ │ ├── 0000_harsh_shiva.sql │ │ │ │ │ ├── 0001_bored_hobgoblin.sql │ │ │ │ │ ├── 0002_smart_power_man.sql │ │ │ │ │ ├── 0003_known_roland_deschain.sql │ │ │ │ │ ├── 0004_majestic_thunderbolt_ross.sql │ │ │ │ │ ├── 0005_tense_sleepwalker.sql │ │ │ │ │ ├── 0006_exotic_kid_colt.sql │ │ │ │ │ ├── 0007_curvy_tarantula.sql │ │ │ │ │ ├── 0008_last_the_santerians.sql │ │ │ │ │ ├── 0009_lucky_power_man.sql │ │ │ │ │ ├── 0010_legal_ben_grimm.sql │ │ │ │ │ ├── 0011_mysterious_stark_industries.sql │ │ │ │ │ ├── 0012_magenta_thing.sql │ │ │ │ │ ├── 0013_chunky_stephen_strange.sql │ │ │ │ │ ├── 0014_chemical_shocker.sql │ │ │ │ │ ├── 0015_colorful_warbird.sql │ │ │ │ │ ├── 0016_curious_carnage.sql │ │ │ │ │ ├── 0017_talented_captain_cross.sql │ │ │ │ │ ├── 0018_dashing_the_fury.sql │ │ │ │ │ ├── 0019_wonderful_shape.sql │ │ │ │ │ ├── 0020_little_marauders.sql │ │ │ │ │ ├── 0021_wakeful_onslaught.sql │ │ │ │ │ ├── 0022_tiny_northstar.sql │ │ │ │ │ ├── 0023_pink_namor.sql │ │ │ │ │ ├── 0024_spooky_alex_power.sql │ │ │ │ │ ├── 0025_colorful_valkyrie.sql │ │ │ │ │ ├── 0026_numerous_slyde.sql │ │ │ │ │ ├── 0027_nostalgic_human_torch.sql │ │ │ │ │ ├── 0028_chief_cyclops.sql │ │ │ │ │ ├── 0029_flaky_gorgon.sql │ │ │ │ │ ├── 0030_common_gabe_jones.sql │ │ │ │ │ ├── 0031_kind_ikaris.sql │ │ │ │ │ ├── 0032_orange_prima.sql │ │ │ │ │ ├── 0033_shiny_sebastian_shaw.sql │ │ │ │ │ ├── 0034_curly_darkstar.sql │ │ │ │ │ ├── 0035_last_valeria_richards.sql │ │ │ │ │ ├── 0036_entry_tag_summary.sql │ │ │ │ │ ├── 0037_bored_the_leader.sql │ │ │ │ │ ├── meta/ │ │ │ │ │ │ ├── 0000_snapshot.json │ │ │ │ │ │ ├── 0001_snapshot.json │ │ │ │ │ │ ├── 0002_snapshot.json │ │ │ │ │ │ ├── 0003_snapshot.json │ │ │ │ │ │ ├── 0004_snapshot.json │ │ │ │ │ │ ├── 0005_snapshot.json │ │ │ │ │ │ ├── 0006_snapshot.json │ │ │ │ │ │ ├── 0007_snapshot.json │ │ │ │ │ │ ├── 0008_snapshot.json │ │ │ │ │ │ ├── 0009_snapshot.json │ │ │ │ │ │ ├── 0010_snapshot.json │ │ │ │ │ │ ├── 0011_snapshot.json │ │ │ │ │ │ ├── 0012_snapshot.json │ │ │ │ │ │ ├── 0013_snapshot.json │ │ │ │ │ │ ├── 0014_snapshot.json │ │ │ │ │ │ ├── 0015_snapshot.json │ │ │ │ │ │ ├── 0016_snapshot.json │ │ │ │ │ │ ├── 0017_snapshot.json │ │ │ │ │ │ ├── 0018_snapshot.json │ │ │ │ │ │ ├── 0019_snapshot.json │ │ │ │ │ │ ├── 0020_snapshot.json │ │ │ │ │ │ ├── 0021_snapshot.json │ │ │ │ │ │ ├── 0022_snapshot.json │ │ │ │ │ │ ├── 0023_snapshot.json │ │ │ │ │ │ ├── 0024_snapshot.json │ │ │ │ │ │ ├── 0025_snapshot.json │ │ │ │ │ │ ├── 0026_snapshot.json │ │ │ │ │ │ ├── 0027_snapshot.json │ │ │ │ │ │ ├── 0028_snapshot.json │ │ │ │ │ │ ├── 0029_snapshot.json │ │ │ │ │ │ ├── 0030_snapshot.json │ │ │ │ │ │ ├── 0031_snapshot.json │ │ │ │ │ │ ├── 0032_snapshot.json │ │ │ │ │ │ ├── 0033_snapshot.json │ │ │ │ │ │ ├── 0034_snapshot.json │ │ │ │ │ │ ├── 0035_snapshot.json │ │ │ │ │ │ ├── 0036_snapshot.json │ │ │ │ │ │ ├── 0037_snapshot.json │ │ │ │ │ │ └── _journal.json │ │ │ │ │ └── migrations.js │ │ │ │ ├── migrator.ts │ │ │ │ ├── schemas/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── collection.ts │ │ │ │ │ ├── entry.ts │ │ │ │ │ ├── feed.ts │ │ │ │ │ ├── image.ts │ │ │ │ │ ├── inbox.ts │ │ │ │ │ ├── internal/ │ │ │ │ │ │ ├── base.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── list.ts │ │ │ │ │ ├── subscription.ts │ │ │ │ │ ├── summary.ts │ │ │ │ │ ├── translation.ts │ │ │ │ │ ├── unread.ts │ │ │ │ │ └── user.ts │ │ │ │ └── types.ts │ │ │ └── tsconfig.json │ │ ├── hooks/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── factory/ │ │ │ │ │ └── createHTMLMediaHook.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal/ │ │ │ │ │ └── for-theme.ts │ │ │ │ ├── optimistic/ │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── strategies.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useOptimisticMutation.ts │ │ │ │ ├── useAnyPointDown.ts │ │ │ │ ├── useControlled.ts │ │ │ │ ├── useCountDown.ts │ │ │ │ ├── useDark.ts │ │ │ │ ├── useElementWidth.ts │ │ │ │ ├── useInputComposition.ts │ │ │ │ ├── useInterval.ts │ │ │ │ ├── useIsOnline.ts │ │ │ │ ├── useLongPress.ts │ │ │ │ ├── useMeasure.ts │ │ │ │ ├── useOnce.ts │ │ │ │ ├── usePageVisibility.ts │ │ │ │ ├── usePrevious.ts │ │ │ │ ├── useRefValue.ts │ │ │ │ ├── useSetState.ts │ │ │ │ ├── useSmoothScroll.ts │ │ │ │ ├── useSyncTheme.ts │ │ │ │ ├── useTitle.ts │ │ │ │ ├── useTriangleMenu.ts │ │ │ │ ├── useTypescriptHappyCallback.ts │ │ │ │ └── useVideo.ts │ │ │ └── tsconfig.json │ │ ├── logger/ │ │ │ ├── electron.ts │ │ │ ├── package.json │ │ │ ├── tsconfig.json │ │ │ └── web.ts │ │ ├── models/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── rsshub.ts │ │ │ └── tsconfig.json │ │ ├── shared/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── auth.ts │ │ │ │ ├── bridge.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── electron.ts │ │ │ │ ├── env.common.ts │ │ │ │ ├── env.desktop.ts │ │ │ │ ├── env.rn.ts │ │ │ │ ├── env.ssr.ts │ │ │ │ ├── event.ts │ │ │ │ ├── global.d.ts │ │ │ │ ├── index.ts │ │ │ │ ├── language.ts │ │ │ │ ├── queue.ts │ │ │ │ ├── review-prompt.test.ts │ │ │ │ ├── review-prompt.ts │ │ │ │ └── settings/ │ │ │ │ ├── constants.ts │ │ │ │ ├── defaults.ts │ │ │ │ ├── hook.ts │ │ │ │ └── interface.ts │ │ │ └── tsconfig.json │ │ ├── store/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── @types/ │ │ │ │ │ ├── default-resource.ts │ │ │ │ │ └── i18next.d.ts │ │ │ │ ├── constants/ │ │ │ │ │ ├── app.ts │ │ │ │ │ └── onboarding.ts │ │ │ │ ├── context.ts │ │ │ │ ├── hydrate.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ └── stream.ts │ │ │ │ ├── modules/ │ │ │ │ │ ├── action/ │ │ │ │ │ │ ├── constant.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── collection/ │ │ │ │ │ │ ├── getter.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── entry/ │ │ │ │ │ │ ├── getter.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── feed/ │ │ │ │ │ │ ├── getter.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── selectors.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── getters.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ └── store.ts │ │ │ │ │ ├── inbox/ │ │ │ │ │ │ ├── getters.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── getters.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── subscription/ │ │ │ │ │ │ ├── getter.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── selectors.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── summary/ │ │ │ │ │ │ ├── enum.ts │ │ │ │ │ │ ├── getters.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── translation/ │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── unread/ │ │ │ │ │ │ ├── getters.ts │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── selectors.ts │ │ │ │ │ │ ├── store.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ └── user/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── getters.ts │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── morph/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── db-store.ts │ │ │ │ │ └── store-db.ts │ │ │ │ ├── reset.ts │ │ │ │ └── types.ts │ │ │ └── tsconfig.json │ │ ├── tracker/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── adapters/ │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── firebase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── posthog.ts │ │ │ │ │ └── proxy.ts │ │ │ │ ├── enums.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manager.ts │ │ │ │ ├── track-manager.ts │ │ │ │ ├── tracker-points.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── tsconfig.json │ │ ├── types/ │ │ │ ├── global.d.ts │ │ │ ├── package.json │ │ │ ├── react-global.d.ts │ │ │ └── vite-env.d.ts │ │ └── utils/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── attribution.ts │ │ │ ├── bind-this.ts │ │ │ ├── chain.ts │ │ │ ├── cjk.ts │ │ │ ├── color.ts │ │ │ ├── data-structure/ │ │ │ │ ├── index.ts │ │ │ │ └── set.ts │ │ │ ├── dom.ts │ │ │ ├── duration.ts │ │ │ ├── environment.ts │ │ │ ├── event-bus.rn.ts │ │ │ ├── event-bus.ts │ │ │ ├── headers.ts │ │ │ ├── html.ts │ │ │ ├── img-proxy.ts │ │ │ ├── index.ts │ │ │ ├── jotai.ts │ │ │ ├── json-codec.ts │ │ │ ├── language.ts │ │ │ ├── link-parser.ts │ │ │ ├── lru-cache.test.ts │ │ │ ├── lru-cache.ts │ │ │ ├── noop.ts │ │ │ ├── ns.ts │ │ │ ├── path-parser.test.ts │ │ │ ├── path-parser.ts │ │ │ ├── react.ts │ │ │ ├── resize.ts │ │ │ ├── scroller.ts │ │ │ ├── url-builder.ts │ │ │ ├── url-for-video.ts │ │ │ ├── utils.spec.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── readability/ │ ├── bump.config.ts │ ├── package.json │ ├── src/ │ │ └── index.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── patches/ │ ├── @microflash__remark-callout-directives.patch │ ├── @mozilla__readability@0.6.0.patch │ ├── @pengx17__electron-forge-maker-appimage.patch │ ├── daisyui@4.12.24.patch │ ├── re-resizable@6.11.2.patch │ ├── react-native-sheet-transitions.patch │ ├── react-native-track-player@4.1.1.patch │ └── workbox-precaching.patch ├── plugins/ │ ├── eslint/ │ │ ├── eslint-check-i18n-json.js │ │ ├── eslint-no-debug.js │ │ ├── eslint-package-json.js │ │ └── eslint-recursive-sort.js │ └── utils.js ├── pnpm-workspace.yaml ├── scripts/ │ ├── copy-translation.ts │ ├── increment-build-id.sh │ ├── lib.ts │ ├── mitproxy.py │ ├── run-proxy.sh │ ├── skip-main-app-vercel-build.sh │ ├── svg-to-rn.ts │ └── update-icon.ts ├── tsconfig.json ├── tsslint.config.ts ├── turbo.json ├── vercel.json ├── vitest.workspace.js ├── vitest.workspace.ts └── wiki/ └── contribute-i18n.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/settings.local.json ================================================ { "permissions": { "allow": [ "Bash(gh pr view:*)", "Bash(pnpm bump:*)", "Bash(git add:*)", "Bash(git commit:*)", "Bash(ls:*)", "Bash(npx:*)", "Bash(git stash:*)", "Bash(git show:*)", "Bash(git -C /Users/diygod/Code/Projects/Folo status --short)", "Bash(git -C /Users/diygod/Code/Projects/Folo add apps/mobile/changelog/next.md)", "Bash(git -C /Users/diygod/Code/Projects/Folo commit:*)", "Bash(git checkout:*)", "Bash(git fetch:*)", "Bash(git merge:*)", "Bash(git rm:*)", "Bash(git push:*)", "Bash(gh api:*)", "Bash(pnpm exec vv:*)" ] } } ================================================ FILE: .agents/skills/desktop-release/SKILL.md ================================================ --- name: desktop-release description: 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. disable-model-invocation: true allowed-tools: Bash, Read, Write, Edit, Glob, Grep --- # Desktop Regular Release Perform a regular desktop release. This skill handles the full release workflow from the `dev` branch. ## Pre-flight checks 1. Confirm the current branch is `dev`. If not, abort with a warning. 2. Run `git pull --rebase` in the repo root to ensure the local branch is up to date. 3. Read `apps/desktop/package.json` to get the current `version` and `mainHash`. ## Step 1: Gather changes since last release 1. Find the last release tag: ```bash git tag --sort=-creatordate | grep '^desktop/v' | head -1 ``` 2. Get all commits since that tag on the current branch: ```bash git log ..HEAD --oneline --no-merges ``` 3. Categorize commits into: - **Shiny new things** (feat: commits, new features) - **Improvements** (refactor:, perf:, chore: improvements, dependency updates) - **No longer broken** (fix: commits, bug fixes) - **Thanks** (identify external contributor GitHub usernames from commits) ## Step 2: Update changelog 1. Read `apps/desktop/changelog/next.md`. 2. Present the categorized changes to the user and draft the changelog content. 3. Wait for user confirmation or edits before writing. 4. Write the final content to `apps/desktop/changelog/next.md`, following the template format: ```markdown # What's new in vNEXT_VERSION ## Shiny new things - description of new feature ## Improvements - description of improvement ## No longer broken - description of fix ## Thanks Special thanks to volunteer contributors @username for their valuable contributions ``` 5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump. ## Step 3: Commit changelog updates before bump `nbump` requires a clean working tree. Commit changelog edits before running bump. 1. Stage the changelog update: ```bash git add apps/desktop/changelog/next.md ``` 2. Commit it on `dev`: ```bash git commit -m "docs(desktop): prepare release changelog" ``` 3. If there are no changes to commit, continue without creating an extra commit. ## Step 4: Evaluate mainHash This is critical for determining whether users need a full app update or can use the lightweight renderer hot update. 1. Check what files changed in `apps/desktop/layer/main/` since the last release tag: ```bash git diff ..HEAD --name-only -- apps/desktop/layer/main/ ``` 2. Also check changes to `apps/desktop/package.json` fields other than version/mainHash (since package.json is included in the hash calculation): ```bash git diff ..HEAD -- apps/desktop/package.json ``` **Decision logic:** - 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. - 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. - 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. Present your analysis to the user with: - List of changed files in `layer/main/` - A summary of what changed - Your recommendation (update or skip mainHash) - Ask for explicit confirmation ## Step 5: Save old mainHash and execute bump 1. Save the current mainHash from `apps/desktop/package.json` for later comparison. 2. Verify working tree is clean before bump: ```bash git status --short ``` 3. Change directory to `apps/desktop/` and run the bump: ```bash cd apps/desktop && pnpm bump ``` 4. This command will: - Pull latest changes - Apply changelog (rename next.md to {version}.md, create new next.md) - Recalculate mainHash and write to package.json - Format package.json - Bump minor version - Commit with message `release(desktop): release v{NEW_VERSION}` - Create branch `release/desktop/{NEW_VERSION}` - Push branch and create PR to `main` ## Step 6: Restore mainHash if skipping update If 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. 1. Change back to the repo root first (Step 5 left the working directory at `apps/desktop/`): ```bash cd ../.. ``` 2. Ensure you are on the `release/desktop/{NEW_VERSION}` branch (bump should have switched to it). 3. Replace the recalculated mainHash with the saved old value in `apps/desktop/package.json`. 4. Stage and amend the release commit: ```bash git add apps/desktop/package.json && git commit --amend --no-edit ``` 5. Force push the release branch: ```bash git push --force origin release/desktop/{NEW_VERSION} ``` If Step 4 decided mainHash SHOULD be updated, skip this step entirely — the bump already wrote the correct new value. ## Step 7: Verify 1. Confirm the PR was created successfully by checking the output. 2. Report the new version number and PR URL to the user. 3. Summarize: - New version: v{NEW_VERSION} - mainHash updated: yes/no (and why) - Changelog highlights - PR URL ## Reference - Bump config: `apps/desktop/bump.config.ts` - Changelog dir: `apps/desktop/changelog/` - Changelog template: `apps/desktop/changelog/next.template.md` - mainHash generator: `apps/desktop/plugins/vite/generate-main-hash.ts` - Hot updater logic: `apps/desktop/layer/main/src/updater/hot-updater.ts` - CI build workflow: `.github/workflows/build-desktop.yml` - Tag workflow: `.github/workflows/tag.yml` ================================================ FILE: .agents/skills/installing-mobile-preview-builds/SKILL.md ================================================ --- name: installing-mobile-preview-builds description: 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. disable-model-invocation: true allowed-tools: Bash, Read, Glob, Grep argument-hint: "[device-udid-or-name(optional)]" --- # Install Mobile Preview Build (iOS) Use this skill to create a fresh local `preview` iOS build and install it on a connected iPhone. ## Inputs - Optional `$ARGUMENTS`: device identifier (UDID or exact device name). - If no argument is provided, auto-select the first paired iPhone from `xcrun devicectl list devices`. ## Workflow 1. Validate repo and tooling. - Run from repo root and ensure `apps/mobile` exists. - Verify `pnpm`, `xcrun`, `xcodebuild`, and `eas-cli` are available. - Verify EAS login: ```bash cd apps/mobile pnpm dlx eas-cli whoami ``` 2. Resolve target device. - List paired devices: ```bash xcrun devicectl list devices ``` - Choose device in this order: - `$ARGUMENTS` if provided and matches exactly one device. - Otherwise, first paired iPhone. 3. Trigger local `preview` iOS build. ```bash mkdir -p .context/preview-install cd apps/mobile pnpm dlx eas-cli build -p ios --profile preview --non-interactive --local --output=./build-preview.ipa cd ../.. cp apps/mobile/build-preview.ipa .context/preview-install/folo-preview.ipa ``` 4. Install to device locally. ```bash unzip -q -o .context/preview-install/folo-preview.ipa -d .context/preview-install/unpacked APP_PATH=$(find .context/preview-install/unpacked/Payload -maxdepth 1 -name '*.app' -type d | head -n 1) xcrun devicectl device install app --device "" "$APP_PATH" ``` 5. Try launching app. ```bash xcrun devicectl device process launch --device "" is.follow --activate ``` - If launch fails due to locked device, instruct the user to unlock iPhone and open `Folo` manually. ## Failure Handling - If local build fails, report: - build mode (`local`) - failing command - key error message from command output - If app config fails with `Assets source directory not found ... /out/rn-web`, prebuild assets then retry once: ```bash pnpm --filter @follow/rn-micro-web-app build --outDir out/rn-web/html-renderer ``` ## Output Format Always return: 1. Build mode (`local`) and final status. 2. Local IPA path. 3. Target device identifier. 4. Install result (`installed` or `failed`) and launch result. 5. Next action for the user if manual action is required. ================================================ FILE: .agents/skills/mobile-e2e/SKILL.md ================================================ --- name: mobile-e2e description: 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. disable-model-invocation: true allowed-tools: Bash, Read, Write, Edit, Glob, Grep --- # Mobile E2E Run the mobile Maestro tests for `apps/mobile`. ## Files that matter - Runner: `apps/mobile/e2e/run-maestro.sh` - iOS auth flow: `apps/mobile/e2e/flows/ios/auth.yaml` - Android auth flow: `apps/mobile/e2e/flows/android/core.yaml` - Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml` - Artifacts: `apps/mobile/e2e/artifacts/` ## Always do first From repo root: ```bash cd apps/mobile pnpm run e2e:doctor pnpm run typecheck ``` ## iOS Use a simulator `.app` build, not an Expo development client. ### Preferred simulator Prefer the **latest installed iOS runtime** and a **latest-generation iPhone simulator**. When multiple simulators are available, bias toward the newest iPhone model on the newest installed iOS version. ### Boot simulator ```bash xcrun simctl boot xcrun simctl bootstatus -b open -a Simulator --args -CurrentDeviceUDID ``` ### App bundle `run-maestro.sh` can resolve the app bundle from one of these sources: - `MAESTRO_IOS_APP_PATH` - a local `build-*.tar.gz` in `apps/mobile` - an existing `DerivedData/.../Release-iphonesimulator/Folo.app` If none of those exist, build one first. ### Build simulator app when missing If `Folo.app` is not available yet: ```bash cd apps/mobile/ios pod install xcodebuild -workspace Folo.xcworkspace \ -scheme Folo \ -configuration Release \ -sdk iphonesimulator \ -destination 'id=' \ build ``` ### Apple Silicon simulator optimization When 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: ```bash xcodebuild ... \ ONLY_ACTIVE_ARCH=YES \ ARCHS=arm64 ``` Use 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. Expected output pattern: ```bash ~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app ``` ### Run iOS auth flow ```bash cd apps/mobile MAESTRO_IOS_DEVICE_ID= \ MAESTRO_IOS_APP_PATH= \ pnpm run e2e:ios ``` ## Android Use a **release APK**, not an Expo development build. ### Java Use Android Studio bundled JBR: ```bash export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" export PATH="$JAVA_HOME/bin:$PATH" ``` ### Android SDK ```bash export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" ``` If `apps/mobile/android/local.properties` is missing, create it with: ```bash echo "sdk.dir=$HOME/Library/Android/sdk" > apps/mobile/android/local.properties ``` ### Build release APK If `apps/mobile/android` does not exist locally, generate it first with Expo prebuild / run-android tooling. Then build the release APK: ```bash cd apps/mobile/android ./gradlew app:assembleRelease --console=plain ``` Expected APK path: ```bash apps/mobile/android/app/build/outputs/apk/release/app-release.apk ``` ### Install to emulator ```bash adb -s emulator-5554 install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk ``` ### Run Android auth flow Start a booted emulator first, then: ```bash cd apps/mobile pnpm run e2e:android ``` ## Result checks Successful auth validation means: - register flow finishes - sign-out reaches `login-screen` - login flow makes `login-screen` disappear ## Debugging output Inspect these folders after a run: ```bash apps/mobile/e2e/artifacts/ios/ apps/mobile/e2e/artifacts/android/ ``` For a one-off focused run, invoke Maestro directly against a single flow and a custom debug directory. ================================================ FILE: .agents/skills/mobile-release/SKILL.md ================================================ --- name: mobile-release description: 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. disable-model-invocation: true allowed-tools: Bash, Read, Write, Edit, Glob, Grep --- # Mobile Regular Release Perform a regular mobile release. This skill handles the full release workflow from the `dev` branch. ## Pre-flight checks 1. Confirm the current branch is `dev`. If not, abort with a warning. 2. Run `git pull --rebase` in the repo root to ensure the local branch is up to date. 3. Read `apps/mobile/package.json` to get the current `version`. ## Step 1: Gather changes since last release 1. Find the last release tag (both old `mobile@` and new `mobile/v` prefixes exist): ```bash git tag --sort=-creatordate | grep -E '^mobile[@/]' | head -1 ``` 2. If no tag found, find the last release commit by matching only the subject line: ```bash git log --format="%H %s" | grep "^[a-f0-9]* release(mobile): release v" | head -1 | awk '{print $1}' ``` 3. Get all commits since the last release on the current branch: ```bash git log ..HEAD --oneline --no-merges ``` 4. Categorize commits into: - **Shiny new things** (feat: commits, new features) - **Improvements** (refactor:, perf:, chore: improvements, dependency updates) - **No longer broken** (fix: commits, bug fixes) - **Thanks** (identify external contributor GitHub usernames from commits) ## Step 2: Update changelog 1. Read `apps/mobile/changelog/next.md`. 2. Present the categorized changes to the user and draft the changelog content. 3. Wait for user confirmation or edits before writing. 4. Write the final content to `apps/mobile/changelog/next.md`, following the template format: ```markdown # What's New in vNEXT_VERSION ## Shiny new things - description of new feature ## Improvements - description of improvement ## No longer broken - description of fix ## Thanks Special thanks to volunteer contributors @username for their valuable contributions ``` 5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump. ## Step 3: Commit changelog updates before bump `nbump` requires a clean working tree. Commit changelog edits before running bump. 1. Stage the changelog update: ```bash git add apps/mobile/changelog/next.md ``` 2. Commit it on `dev`: ```bash git commit -m "docs(mobile): prepare release changelog" ``` 3. If there are no changes to commit, continue without creating an extra commit. ## Step 4: Execute bump 1. Verify working tree is clean before bump: ```bash git status --short ``` 2. Change directory to `apps/mobile/` and run the bump: ```bash cd apps/mobile && pnpm bump ``` 3. This is an interactive `nbump` command that prompts for version selection. It will: - Pull latest changes - Apply changelog (rename next.md to {version}.md, create new next.md from template) - Format package.json with eslint + prettier - Bump version in `package.json` - Update `ios/Folo/Info.plist`: - Set `CFBundleShortVersionString` to the new version - Increment `CFBundleVersion` (build number) by 1 - Commit with message `release(mobile): release v{NEW_VERSION}` - Create branch `release/mobile/{NEW_VERSION}` - Push branch and create PR to `mobile-main` ## Step 5: Verify 1. Confirm the PR was created successfully by checking the output. 2. Report the new version number and PR URL to the user. 3. Summarize: - New version: v{NEW_VERSION} - Changelog highlights - PR URL ## Post-release (manual steps, inform user) After the release PR is merged to `mobile-main`: 1. **Trigger production builds** via GitHub Actions `workflow_dispatch`: - Go to "Build iOS" workflow, select `mobile-main` branch, profile = `production` - Go to "Build Android" workflow, select `mobile-main` branch, profile = `production` 2. Production builds auto-submit to App Store (via `eas submit`) and Google Play (as draft). 3. After submission, go to App Store Connect and Google Play Console to complete the review/release process. ## Reference - Bump config: `apps/mobile/bump.config.ts` - Changelog dir: `apps/mobile/changelog/` - Changelog template: `apps/mobile/changelog/next.template.md` - Apply changelog script: `apps/mobile/scripts/apply-changelog.ts` - EAS config: `apps/mobile/eas.json` - App config: `apps/mobile/app.config.ts` - iOS Info.plist: `apps/mobile/ios/Folo/Info.plist` - CI build iOS: `.github/workflows/build-ios.yml` - CI build Android: `.github/workflows/build-android.yml` ================================================ FILE: .agents/skills/mobile-self-test/SKILL.md ================================================ --- name: mobile-self-test description: 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. disable-model-invocation: true allowed-tools: Bash, Read, Write, Edit, Glob, Grep --- # Mobile Self Test Validate a mobile change after implementation. This 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. ## Files that matter - Reference skill: `../mobile-e2e/SKILL.md` - Runner: `apps/mobile/e2e/run-maestro.sh` - iOS register flow: `apps/mobile/e2e/flows/ios/register.yaml` - Android register flow: `apps/mobile/e2e/flows/android/register.yaml` - Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml` - Expo config: `apps/mobile/app.config.ts` - Build profiles: `apps/mobile/eas.json` - Mobile artifacts: `apps/mobile/e2e/artifacts/` - Local server repo: `/Users/diygod/Code/Projects/follow-server` ## Default assumptions - Prefer **iOS simulator** unless the user explicitly asks for Android or the change is Android-specific. - Default to **prod API mode** when the user did not specify a mode. - 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`. - Keep `EXPO_PUBLIC_E2E_LANGUAGE=en` unless the user explicitly wants another language. The existing Maestro flows assume English UI. ## Simulator and emulator isolation This section overrides the shared device-selection guidance from `../mobile-e2e/SKILL.md`. Self-test runs must be isolated because other agents may be using simulators or emulators on the same machine. - Always create a dedicated temporary simulator or emulator for the current run. - Never reuse `booted`, an already-running simulator, or a generic Android serial such as `emulator-5554`. - Record the temporary device name and identifier immediately after creation, then use only that stored identifier for build, install, launch, screenshots, and Maestro. - Register cleanup before booting the device so the temporary simulator or emulator is deleted even if the test fails midway. - If cleanup fails, report the leftover device name and identifier in the final response. ## Decide API mode first Use this decision order: 1. If the user explicitly asks for `prod` or `local`, obey that. 2. Otherwise, if the task depends on local backend changes or local server behavior, use `local`. 3. Otherwise, use `prod`. Map the chosen mode into the release build: - `prod` mode: `EXPO_PUBLIC_E2E_ENV_PROFILE=prod` - `local` mode: `EXPO_PUBLIC_E2E_ENV_PROFILE=local` Do not silently reuse a build from the other mode. Rebuild the release app when switching between `prod` and `local`. ## Always do first From repo root: ```bash cd apps/mobile pnpm run e2e:doctor pnpm run typecheck ``` If these fail, stop and report the blocker before attempting simulator work. ## Local server mode `local` mode requires the local server to be available at `http://localhost:3000`. Before starting anything, check whether it is already running. Do not start a duplicate server. ```bash FOLLOW_SERVER_LOG=/tmp/follow-server-dev-core.log if pgrep -af "pnpm dev:core" >/dev/null 2>&1 || lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then echo "follow-server already running" else ( cd /Users/diygod/Code/Projects/follow-server nohup pnpm dev:core >"$FOLLOW_SERVER_LOG" 2>&1 & ) fi for _ in $(seq 1 60); do nc -z 127.0.0.1 3000 >/dev/null 2>&1 && break sleep 2 done nc -z 127.0.0.1 3000 >/dev/null 2>&1 ``` If 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. ## Release build profiles for self-test Use release-style builds so the test matches user-facing behavior. - iOS simulator builds: `PROFILE=e2e-ios-simulator` - Android emulator builds: `PROFILE=e2e-android` Always pair those with the chosen API mode and language: ```bash export EXPO_PUBLIC_E2E_ENV_PROFILE= export EXPO_PUBLIC_E2E_LANGUAGE=en ``` ## iOS workflow Do 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. ### Create a dedicated temporary simulator Pick the latest available iOS runtime and a recent iPhone device type, then create a temporary simulator. ```bash IOS_SIM_NAME="CodexSelfTest-$(date +%Y%m%d-%H%M%S)" IOS_RUNTIME_ID="" IOS_DEVICE_TYPE_ID="" IOS_UDID="$(xcrun simctl create "$IOS_SIM_NAME" "$IOS_DEVICE_TYPE_ID" "$IOS_RUNTIME_ID")" cleanup_ios_simulator() { xcrun simctl shutdown "$IOS_UDID" >/dev/null 2>&1 || true xcrun simctl delete "$IOS_UDID" >/dev/null 2>&1 || true } trap cleanup_ios_simulator EXIT ``` Do not switch to another simulator after `IOS_UDID` is created. ### Boot the dedicated simulator ```bash xcrun simctl boot "$IOS_UDID" xcrun simctl bootstatus "$IOS_UDID" -b open -a Simulator --args -CurrentDeviceUDID "$IOS_UDID" ``` If other simulators are already booted, leave them alone and continue using only `IOS_UDID`. ### Build release simulator app ```bash cd apps/mobile/ios pod install PROFILE=e2e-ios-simulator \ EXPO_PUBLIC_E2E_ENV_PROFILE= \ EXPO_PUBLIC_E2E_LANGUAGE=en \ xcodebuild -workspace Folo.xcworkspace \ -scheme Folo \ -configuration Release \ -sdk iphonesimulator \ -destination "id=$IOS_UDID" \ clean build ``` On 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: ```bash ONLY_ACTIVE_ARCH=YES \ ARCHS=arm64 ``` Do not use that optimization when you need a universal simulator bundle for other machines or when the host Mac is Intel. Expected output pattern: ```bash ~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app ``` ### Install app on simulator ```bash xcrun simctl install "$IOS_UDID" xcrun simctl launch "$IOS_UDID" is.follow ``` ## Android workflow Reuse the Java and Android SDK setup from `../mobile-e2e/SKILL.md`. Do not attach to a shared emulator. Create a dedicated temporary AVD for this run and keep using only its recorded serial. ### Create a dedicated temporary AVD Create a fresh AVD backed by an installed phone system image. ```bash ANDROID_AVD_NAME="codex-self-test-$(date +%Y%m%d-%H%M%S)" ANDROID_AVD_PACKAGE="" ANDROID_AVD_DEVICE="" avdmanager create avd -n "$ANDROID_AVD_NAME" -k "$ANDROID_AVD_PACKAGE" -d "$ANDROID_AVD_DEVICE" --force ANDROID_EMULATOR_PORT="" for port in 5554 5556 5558 5560 5562 5564; do if ! lsof -nP -iTCP:$port >/dev/null 2>&1 && ! lsof -nP -iTCP:$((port + 1)) >/dev/null 2>&1; then ANDROID_EMULATOR_PORT="$port" break fi done [ -n "$ANDROID_EMULATOR_PORT" ] || { echo "No free Android emulator port found" exit 1 } ANDROID_DEVICE_ID="emulator-$ANDROID_EMULATOR_PORT" cleanup_android_emulator() { adb -s "$ANDROID_DEVICE_ID" emu kill >/dev/null 2>&1 || true avdmanager delete avd -n "$ANDROID_AVD_NAME" >/dev/null 2>&1 || true } trap cleanup_android_emulator EXIT ``` ### Boot the dedicated emulator ```bash emulator @"$ANDROID_AVD_NAME" -port "$ANDROID_EMULATOR_PORT" -no-snapshot -wipe-data & adb -s "$ANDROID_DEVICE_ID" wait-for-device ``` If other emulators are already booted, ignore them and continue using only `ANDROID_DEVICE_ID`. If `apps/mobile/android` does not exist locally, generate it first. ```bash cd apps/mobile pnpm expo prebuild android ``` ### Build release APK ```bash cd apps/mobile/android PROFILE=e2e-android \ EXPO_PUBLIC_E2E_ENV_PROFILE= \ EXPO_PUBLIC_E2E_LANGUAGE=en \ ./gradlew clean app:assembleRelease --console=plain ``` Expected APK path: ```bash apps/mobile/android/app/build/outputs/apk/release/app-release.apk ``` ### Install app on emulator ```bash adb -s "$ANDROID_DEVICE_ID" install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk adb -s "$ANDROID_DEVICE_ID" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1 ``` ## Cleanup is mandatory Delete the temporary simulator or emulator created for the run before returning control to the user. ### iOS cleanup ```bash xcrun simctl shutdown "$IOS_UDID" >/dev/null 2>&1 || true xcrun simctl delete "$IOS_UDID" >/dev/null 2>&1 || true ``` ### Android cleanup ```bash adb -s "$ANDROID_DEVICE_ID" emu kill >/dev/null 2>&1 || true avdmanager delete avd -n "$ANDROID_AVD_NAME" >/dev/null 2>&1 || true ``` Do not leave temporary devices behind for other agents. ## Choose the auth strategy This is the core difference from `mobile-e2e`. ### A. Change is **not** related to login or registration Use the existing automated **registration** flow first to bootstrap a clean logged-in account, then do the real verification visually. Examples: - timeline behavior - subscription management - onboarding content after auth - settings pages unrelated to sign-in state - player, reader, share, discover, profile editing Generate a unique test account before running the flow: ```bash export E2E_PASSWORD='Password123!' export E2E_EMAIL="folo-self-test-$(date +%Y%m%d%H%M%S)@example.com" ``` For non-auth iOS self-tests, bootstrap auth through the standard iOS runner mode after the app has been installed and launched once: ```bash cd apps/mobile pnpm run e2e:ios:bootstrap ``` This 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. #### iOS registration bootstrap ```bash cd apps/mobile maestro test --format junit --platform ios --device "$IOS_UDID" \ --debug-output e2e/artifacts/ios/register-bootstrap \ -e E2E_EMAIL="$E2E_EMAIL" \ -e E2E_PASSWORD="$E2E_PASSWORD" \ e2e/flows/ios/register.yaml ``` #### Android registration bootstrap ```bash cd apps/mobile maestro test --format junit --platform android --device "$ANDROID_DEVICE_ID" \ --debug-output e2e/artifacts/android/register-bootstrap \ -e E2E_EMAIL="$E2E_EMAIL" \ -e E2E_PASSWORD="$E2E_PASSWORD" \ e2e/flows/android/register.yaml ``` After registration succeeds, continue with screenshot-driven visual testing. ### B. Change **is** related to login, registration, logout, session handling, auth validation, or onboarding gates Do **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. Examples: - register screen changes - login screen changes - credential validation changes - auth toggle changes - logout behavior - auth/session restoration - onboarding shown or hidden based on auth state For auth-related work: - create the test account manually through the UI if needed - use screenshots after every critical step - verify success and error states visually - keep a clean record of the exact screen sequence shown to the user ## Screenshot-driven visual testing Once 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. Create a timestamped artifact folder first: ```bash REPO_ROOT="$(git rev-parse --show-toplevel)" ARTIFACT_DIR="$REPO_ROOT/apps/mobile/e2e/artifacts/manual/$(date +%Y%m%d-%H%M%S)--" mkdir -p "$ARTIFACT_DIR" ``` Capture screenshots after each meaningful checkpoint. ### iOS screenshot command ```bash xcrun simctl io "$IOS_UDID" screenshot "$ARTIFACT_DIR/.png" ``` ### Android screenshot command ```bash adb -s "$ANDROID_DEVICE_ID" exec-out screencap -p > "$ARTIFACT_DIR/.png" ``` Minimum screenshot set for a complete self-test: 1. entry screen before the changed flow 2. the changed screen or interaction in progress 3. the final success state or the reproduced bug state Add more screenshots when the flow has multiple important states. Do not report success without screenshot evidence. ## What to validate visually Use the screenshots to confirm at least these points when relevant: - the correct screen is reached - the changed control, copy, or layout is visible - loading, empty, error, and success states look correct - the operation completes without obvious regressions or blocking dialogs - the app is talking to the intended environment (`prod` or `local`) If the UI or behavior is ambiguous, capture another screenshot instead of guessing. ## Final user-facing output The final response must include: - API mode used and why it was chosen - platform and dedicated simulator/emulator name plus identifier used - cleanup result for the temporary simulator/emulator - whether the local server was reused or started, plus log path if started - build command used - whether auth bootstrap was automated or fully visual - concise step-by-step result summary - pass/fail conclusion - screenshot evidence with absolute file paths If 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. ## Failure handling - If doctor, typecheck, build, install, or server startup fails, stop and report the exact failing command. - If `local` mode cannot reach the local server, do not silently fall back to `prod`. - 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. ================================================ FILE: .agents/skills/update-deps/SKILL.md ================================================ --- name: update-deps description: 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. disable-model-invocation: true allowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Task --- # Update All Dependencies Update 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. ## Step 0: Ask for backend directory Ask the user for the backend project directory path. Do NOT hardcode any path. Example prompt: > Please provide the backend project directory path (e.g., `/path/to/backend`). Wait for the user's response before proceeding. Save the path as `BACKEND_DIR` for later use. ## Step 1: Analyze current dependencies ### Frontend (current repo) 1. Run `pnpm outdated --recursive` in the current repo root to identify all outdated dependencies. 2. Save the full output for later analysis. ### Backend 1. Run `pnpm outdated --recursive` in `BACKEND_DIR` to identify all outdated dependencies. 2. Save the full output for later analysis. Present the user with a summary of how many dependencies are outdated in each project. ## Step 2: Update dependencies ### Strategy Update in two phases to isolate issues: **Phase 1 — Patch and minor updates (safer):** From the `pnpm outdated` output, identify all dependencies where the update is a patch or minor version bump. Update them in batch: 1. Frontend: For each patch/minor outdated package, run `pnpm update @latest --recursive` in the repo root. 2. Backend: For each patch/minor outdated package, run `pnpm update @latest --recursive` in `BACKEND_DIR`. > **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. **Phase 2 — Major updates (requires careful review):** For each dependency with a major version update available: 1. Identify the dependency name, current version, and latest version. 2. **Read the changelog** (Step 3) before updating. 3. Only update after confirming no blocking breaking changes. 4. Use `pnpm update @latest --recursive` to update specific packages. **Important pnpm workspace notes:** - 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. - Both projects use `save-exact=true`, so versions are pinned without `^` or `~`. - 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. ## Step 3: Review changelogs for major updates For each dependency with a **major version update**, you MUST read the changelog before updating. ### How to find changelogs Use these methods in order of preference: 1. **npm registry**: Run `npm view repository.url` to find the repo, then check for `CHANGELOG.md` or GitHub releases. 2. **GitHub releases**: Search `https://github.com///releases` using WebFetch. 3. **Web search**: Use WebSearch to find ` changelog to `. ### What to look for - **Breaking changes**: API removals, renamed exports, changed defaults, dropped Node.js version support. - **Deprecated features**: Features being removed in future versions. - **Migration guides**: Official upgrade instructions. - **Peer dependency changes**: New or changed peer dependency requirements. ### Document findings For each major update, record: - Package name and version change (e.g., `foo: 2.x → 3.x`) - Breaking changes summary - Whether our code is affected (and how) ## Step 4: Check affected code For each dependency with breaking changes identified in Step 3: 1. Use `Grep` to find all imports and usages of the affected package across the relevant project (frontend or backend). 2. Read the files containing usages. 3. Compare the usage against the breaking change description. 4. If our code uses an affected API: - Attempt to fix the code following the migration guide. - If the fix is complex or risky, **skip updating this dependency** and note it in the summary. 5. If our code does NOT use any affected API, proceed with the update. ## Step 5: Run tests and checks After all updates are applied: ### Frontend Run these commands sequentially in the repo root and capture results: ```bash pnpm install pnpm typecheck pnpm test pnpm lint ``` ### Backend Run these commands sequentially in `BACKEND_DIR` and capture results: ```bash pnpm install pnpm typecheck pnpm test pnpm lint ``` ### Handle failures - **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. - **Test failures**: Analyze the failure, check if it's related to a dependency update, and fix or revert. - **Lint errors**: Run `pnpm lint:fix` first. If issues persist, fix manually or revert the causing update. Repeat the test cycle until all checks pass. ## Step 6: Summary Present the user with a comprehensive summary: ### Update report ``` ## Dependencies Updated ### Frontend - : (patch/minor/major) - ... ### Backend - : (patch/minor/major) - ... ## Skipped Updates (with reasons) - : - ... ## Key Changelog Highlights ### Breaking Changes Applied - : ### Notable New Features - : ### Deprecation Warnings - : ## Test Results - Frontend typecheck: ✅/❌ - Frontend tests: ✅/❌ - Frontend lint: ✅/❌ - Backend typecheck: ✅/❌ - Backend tests: ✅/❌ - Backend lint: ✅/❌ ``` Ask the user if they want to commit the changes. ================================================ FILE: .cursorignore ================================================ # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.splinecode filter=lfs diff=lfs merge=lfs -text ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐞 Bug report description: Report an issue labels: [pending triage, bug] type: Bug body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: dropdown id: platform attributes: label: Platform description: On which platforms does this bug occur? multiple: true options: - Desktop - macOS - Desktop - Windows - Desktop - Linux - Desktop - Web - Mobile - iOS - Mobile - Android - Mobile - Web validations: required: true - type: textarea id: bug-description attributes: label: Describe the bug description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! placeholder: Bug description validations: required: true - type: input id: entryId attributes: label: Entry ID description: Please provide the entry id of the entry that is causing the issue. If you are not sure, please provide the entry url. - type: textarea id: relevant-information attributes: label: Relevant Information description: Please provide your user id, feed id, feed url, or any other information that can help us reproduce the issue. placeholder: User ID, Feed ID, Feed URL, etc. validations: required: false - type: textarea id: reproduction attributes: label: Reproduction Video description: If possible, please provide a video that demonstrates the bug. validations: required: false - type: textarea id: environment attributes: label: Environment 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. - type: checkboxes id: checkboxes attributes: label: Validations description: Before submitting the issue, please make sure you do the following options: - label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate. required: true - label: Check that this is a concrete bug. For Q&A, please open a GitHub Discussion instead. required: true - label: This issue is valid required: true - type: checkboxes id: contributions attributes: label: Contributions description: Please note that Open Source projects are maintained by volunteers, where your cases might not be always relevant to the others. It would make things move faster if you could help investigate and propose solutions. options: - label: I am willing to submit a PR to fix this issue - label: I am willing to submit a PR with failing tests (actually just go ahead and do it, thanks!) ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 💬 Follow's Discord Server url: https://discord.gg/tUDVZjEr about: Want to discuss / chat with the community? Here you go! - name: Discuss an issue url: https://github.com/RSSNext/Follow/discussions about: For general questions, ideas, or non-bug related discussions, please use GitHub Discussions. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 🚀 New feature proposal description: Propose a new feature labels: [enhancement] type: Feature body: - type: markdown attributes: value: | Thanks for your interest in the project and taking the time to fill out this feature report! - type: textarea id: feature-description attributes: label: Clear and concise description of the problem description: "As a developer using this project I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description. Thanks!" validations: required: true - type: textarea id: suggested-solution attributes: label: Suggested solution description: "In module [xy] we could provide following implementation..." validations: required: true - type: textarea id: alternative attributes: label: Alternative description: Clear and concise description of any alternative solutions or features you've considered. - type: textarea id: additional-context attributes: label: Additional context description: Any other context or screenshots about the feature request here. - type: checkboxes id: checkboxes attributes: label: Validations description: Before submitting the issue, please make sure you do the following options: - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate. required: true - label: This issue is valid required: true ================================================ FILE: .github/ISSUE_TEMPLATE/i18n.yml ================================================ name: 🌐 Internationalization (i18n) description: Contribute to or report issues with translations title: "[i18n]: " labels: ["i18n", "triage"] body: - type: markdown attributes: value: | Thanks for taking the time to contribute to our internationalization efforts! Before proceeding, please check our [i18n Contribution Guidelines](https://github.com/RSSNext/Follow/blob/dev/wiki/contribute-i18n.md) for detailed instructions. - type: dropdown id: type attributes: label: Type of i18n contribution options: - New language support - Update existing translations - Report incorrect translation - Other i18n-related issue validations: required: true - type: input id: language attributes: label: Language description: What language are you contributing to or reporting about? placeholder: e.g., Spanish, French, Japanese validations: required: true - type: textarea id: description attributes: label: Description description: Please provide details about your contribution or the issue you're reporting. placeholder: | For new languages: List any specific challenges or considerations. For updates: Describe what you're changing and why. For issues: Provide the incorrect translation and suggest a correction. validations: required: true - type: textarea id: additional-context attributes: label: Additional context description: Add any other context, screenshots, or file references here. - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com/code-of-conduct) options: - label: I agree to follow this project's Code of Conduct required: true - label: This issue is valid required: true ================================================ FILE: .github/ISSUE_TEMPLATE/typo.yml ================================================ name: 👀 Typo / Grammar fix description: You can just go ahead and send a PR! Thank you! labels: [] body: - type: markdown attributes: value: | ## PR Welcome! If the typo / grammar issue is trivial and straightforward, you can help by **directly sending a quick pull request**! If you spot multiple of them, we suggest combining them into a single PR. Thanks! - type: textarea id: context attributes: label: Additional context - type: checkboxes id: checkboxes attributes: label: Validations description: Before submitting the issue, please make sure you do the following options: - label: This issue is valid required: true ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### Description ### PR Type - [ ] Feature - [ ] Bugfix - [ ] Hotfix - [ ] Other (please describe): ### Screenshots (if UI change) ### Demo Video (if new feature) ### Linked Issues ### Additional context ### Changelog - [ ] I have updated the changelog/next.md with my changes. ================================================ FILE: .github/actions/setup-version/action.yml ================================================ name: Setup Version description: "Setup Version" inputs: type: required: true description: "Type of the app, either 'desktop' or 'mobile'" default: "desktop" outputs: APP_VERSION: description: "App Version" value: ${{ steps.version.outputs.APP_VERSION }} runs: using: "composite" steps: - name: "Write Version" id: version shell: bash run: | if [ "${{ github.ref_type }}" == "tag" ]; then # Handle new tag format: desktop/v1.2.3 or mobile/v1.2.3 if [[ "${{ github.ref_name }}" =~ ^(desktop|mobile)/v(.*)$ ]]; then APP_VERSION="${BASH_REMATCH[2]}" else # Fallback for old format: v1.2.3 APP_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//') fi else if [ "${{ inputs.type }}" == "desktop" ]; then APP_VERSION=$(node -p "require('./apps/desktop/package.json').version") else APP_VERSION=$(node -p "require('./apps/mobile/package.json').version") fi fi echo $APP_VERSION echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT" ================================================ FILE: .github/actions/setup-xcode/action.yml ================================================ name: "Setup Xcode" description: "Setup specific Xcode version for iOS builds" inputs: xcode-version: description: "Xcode version to use" required: false default: "26.0.1" runs: using: "composite" steps: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ inputs.xcode-version }} - name: Setup Xcode Environment run: | set -e echo "=== Checking CI Environment ===" echo "Current user: $(whoami)" echo "User groups: $(groups)" echo "Sudo available: $(sudo -n true 2>/dev/null && echo 'YES' || echo 'NO')" echo "Xcode path: $(xcode-select -p 2>/dev/null || echo 'Not set')" # Set Xcode path and accept license echo "=== Setting up Xcode ===" if command -v sudo >/dev/null && sudo -n true 2>/dev/null; then sudo xcode-select -s /Applications/Xcode_${{ inputs.xcode-version }}.app/Contents/Developer sudo xcodebuild -license accept echo "Using sudo for Xcode setup" else xcode-select -s /Applications/Xcode_${{ inputs.xcode-version }}.app/Contents/Developer xcodebuild -license accept echo "Using direct access for Xcode setup" fi echo "Final Xcode path: $(xcode-select -p)" shell: bash - name: Start Simulator Service run: | echo "=== Starting Simulator Service ===" # Try to start simulator service (ignore errors if already running) sudo launchctl load -w /System/Library/LaunchDaemons/com.apple.CoreSimulator.CoreSimulatorService.plist 2>/dev/null || { echo "CoreSimulator service might already be running or command unavailable" } # Alternative for newer macOS versions sudo launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.CoreSimulator.CoreSimulatorService.plist 2>/dev/null || { echo "Bootstrap command failed or service already running" } # Wait a moment for service to start sleep 3 shell: bash - name: Download iOS Platform run: | set -e echo "=== Checking Available SDKs ===" echo "Currently available SDKs:" xcodebuild -showsdks | grep -E "(iOS|Simulator)" || echo "No iOS SDKs found yet" echo "=== Attempting iOS Platform Download ===" # Method 1: Try standard download if xcodebuild -downloadPlatform iOS -quiet 2>/dev/null; then echo "✅ iOS platform downloaded successfully with xcodebuild" elif xcrun xcodebuild -downloadPlatform iOS -quiet 2>/dev/null; then echo "✅ iOS platform downloaded successfully with xcrun xcodebuild" else echo "⚠️ Platform download failed, checking if iOS SDK is already available..." # Check if iOS SDK is available if xcodebuild -showsdks | grep -q "iOS"; then echo "✅ iOS SDK is already available, continuing..." else echo "❌ No iOS SDK found and download failed" echo "Available SDKs:" xcodebuild -showsdks # Try alternative download method echo "Trying alternative download method..." xcodebuild -downloadAllPlatforms -quiet || { echo "❌ All download methods failed" exit 1 } fi fi echo "=== Final SDK Status ===" echo "Available iOS SDKs:" xcodebuild -showsdks | grep -E "(iOS|Simulator)" || echo "No iOS SDKs found" echo "Available simulators:" xcrun simctl list devices available | head -20 || echo "No simulators found" shell: bash - name: Verify Setup run: | echo "=== Verification ===" echo "Xcode version: $(xcodebuild -version | head -1)" echo "Selected Xcode: $(xcode-select -p)" echo "iOS SDK available: $(xcodebuild -showsdks | grep -c iOS || echo 0)" # Test basic functionality if xcodebuild -showsdks | grep -q "iOS"; then echo "✅ Setup completed successfully!" else echo "⚠️ Setup completed but iOS SDK may not be fully available" fi shell: bash ================================================ FILE: .github/advanced-issue-labeler.yml ================================================ policy: - section: - id: [platform] block-list: ["None", "Other"] label: - name: "platform: desktop" keys: ["Desktop - macOS", "Desktop - Windows", "Desktop - Linux", "Desktop - Web"] - name: "platform: mobile" keys: ["Mobile - iOS", "Mobile - Android", "Mobile - Web"] ================================================ FILE: .github/copilot-instructions.md ================================================ Before you start, you need to read and follow the rules in @../CLAUDE.md ================================================ FILE: .github/dependabot.yaml ================================================ version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: weekly day: friday time: "12:00" timezone: Asia/Singapore target-branch: dev ignore: - dependency-name: "@shopify/flash-list" versions: [">1.7.3"] # Stuck by tailwindcss 4 - dependency-name: tailwindcss versions: [">=4.0.0"] - dependency-name: daisyui versions: [">=5.0.0"] # Stuck by expo 52 - dependency-name: react-native versions: [">=0.78.0"] # It's using export map and metro doesn't support it well - dependency-name: unist-util-visit-parents versions: [">=6.0.0"] # electron 35 - dependency-name: electron versions: [">=35.0.0"] - dependency-name: react-native-sheet-transitions versions: [">0.1.2"] # filter not work - dependency-name: unplugin-ast versions: ["0.14.5"] open-pull-requests-limit: 100 groups: minor-and-patch: applies-to: version-updates update-types: - "minor" - "patch" pathed: patterns: - immer - re-resizable - electron-context-menu - "@mozilla/readability" - daisyui - jsonpointer - workbox-precaching - "@pengx17/electron-forge-maker-appimage" - "@microflash/remark-callout-directives" - react-native-track-player - react-native-sheet-transitions - package-ecosystem: github-actions directory: / schedule: interval: daily target-branch: dev open-pull-requests-limit: 100 ================================================ FILE: .github/prompts/similar_issues.prompt.yml ================================================ messages: - role: system content: |- You are a GitHub assistant with access to GitHub Model Context Protocol (MCP) tools in read-only mode. Your task is to search this repository's issues to find previously filed issues similar to the provided issue title and body. Use the GitHub tools via MCP to perform the search and retrieve real issue data (do not fabricate results). Consider semantic similarity across title and body. Exclude issues with the same ID/number as the current issue. Return up to 3 of the most similar past issues. If none are reasonably similar, return an empty list. Output must follow the response schema exactly and include only data you actually retrieved from GitHub tools. The current GitHub repository is: "{{repository}}". - role: user content: |- Find similar issues for this new issue: Title: {{issue_title}} Body: {{issue_body}} model: openai/gpt-4.1-mini responseFormat: json_schema jsonSchema: |- { "name": "similar_issues_result", "strict": true, "schema": { "type": "object", "properties": { "matches": { "type": "array", "items": { "type": "object", "properties": { "number": { "type": "integer" }, "title": { "type": "string" }, "url": { "type": "string" }, "similarity_score": { "type": "number", "minimum": 0, "maximum": 1 } }, "required": ["number", "title", "url", "similarity_score"], "additionalProperties": false } } }, "required": ["matches"], "additionalProperties": false } } ================================================ FILE: .github/scripts/extract-release-info.mjs ================================================ #!/usr/bin/env node /** * Extract release version and platform information from git commit messages * Used by GitHub Actions to determine if a release tag should be created */ import { execSync } from "node:child_process" import { appendFileSync } from "node:fs" // Configuration const RELEASE_PATTERNS = { desktop: /release\(desktop\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i, mobile: /release\(mobile\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i, } const EXIT_CODES = { SUCCESS: 0, GIT_ERROR: 2, ENV_ERROR: 3, OUTPUT_ERROR: 4, } /** * Write environment variable to GitHub Environment * @param {string} key - Environment variable key * @param {string} value - Environment variable value */ function setGitHubEnv(key, value) { try { if (!process.env.GITHUB_ENV) { throw new Error("GITHUB_ENV not set - not running in GitHub Actions") } appendFileSync(process.env.GITHUB_ENV, `${key}=${value}\n`) } catch (error) { console.error(`Failed to set environment variable ${key}:`, error.message) process.exit(EXIT_CODES.ENV_ERROR) } } /** * Write output variable to GitHub Output * @param {string} key - Output key * @param {string} value - Output value */ function setGitHubOutput(key, value) { try { if (!process.env.GITHUB_OUTPUT) { return } appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`) } catch (error) { console.error(`Failed to set output variable ${key}:`, error.message) process.exit(EXIT_CODES.OUTPUT_ERROR) } } /** * Get the latest commit message * @returns {string} Latest commit message */ function getLatestCommitMessage() { try { return execSync("git log -1 --pretty=%B", { encoding: "utf-8" }).toString().trim() } catch (error) { console.error("Failed to get git commit message:", error.message) process.exit(EXIT_CODES.GIT_ERROR) } } /** * Extract release information from commit message * @param {string} commitMessage - Git commit message * @returns {Object|null} Release information or null if no release found */ function extractReleaseInfo(commitMessage) { for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) { const match = commitMessage.match(regex) if (match) { const version = match[1] const tagName = `${platform}/${version}` return { platform, version, tagName, } } } return null } /** * Main execution function */ function main() { try { console.info("Extracting release information from commit message...") const commitMessage = getLatestCommitMessage() console.info(`Commit message: ${commitMessage}`) const releaseInfo = extractReleaseInfo(commitMessage) if (!releaseInfo) { console.info("No desktop or mobile release found in commit message.") process.exit(EXIT_CODES.SUCCESS) } const { platform, version, tagName } = releaseInfo // Set GitHub Environment variables setGitHubEnv("tag_version", tagName) setGitHubEnv("platform", platform) setGitHubEnv("version", version) setGitHubOutput("tag_version", tagName) setGitHubOutput("platform", platform) setGitHubOutput("version", version) console.info(`Found ${platform} release: ${version}`) console.info(`Tag will be created: ${tagName}`) process.exit(EXIT_CODES.SUCCESS) } catch (error) { console.error("Unexpected error:", error.message) process.exit(EXIT_CODES.GIT_ERROR) } } main() ================================================ FILE: .github/workflows/build-android.yml ================================================ name: 🤖 Build Android on: push: branches: - "**" paths: - "apps/mobile/**" - "pnpm-lock.yaml" workflow_dispatch: inputs: profile: type: choice default: preview options: - preview - production description: "Build profile" release: type: boolean default: false description: "Create a release draft for the build" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }} cancel-in-progress: true jobs: build: name: Build Android apk for device if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):')) runs-on: ubuntu-latest steps: - name: 🧹 Claim disk space run: | df -h / sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/local/.ghcup sudo rm -rf /opt/hostedtoolcache/CodeQL sudo rm -rf /usr/share/swift sudo rm -rf /usr/local/julia* df -h / - name: 📦 Checkout code uses: actions/checkout@v6 - name: 📦 Setup pnpm uses: pnpm/action-setup@v4 - name: 🏗 Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" - name: Set up JDK 17 uses: actions/setup-java@v5 with: java-version: "17" distribution: "zulu" - name: Setup Android SDK uses: android-actions/setup-android@v3 - name: 📱 Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies run: pnpm install - name: 🔨 Build Android app working-directory: apps/mobile run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --local --output=${{ github.workspace }}/build.${{ github.event.inputs.profile == 'production' && 'aab' || 'apk' }} - name: 📤 Upload apk Artifact if: github.event.inputs.profile != 'production' uses: actions/upload-artifact@v7 with: name: app-android path: ${{ github.workspace }}/build.apk retention-days: 90 - name: 📤 Upload aab Artifact if: github.event.inputs.profile == 'production' uses: actions/upload-artifact@v7 with: name: aab-android path: ${{ github.workspace }}/build.aab retention-days: 90 - name: Submit to Google Play if: github.event.inputs.profile == 'production' working-directory: apps/mobile run: eas submit --platform android --path ${{ github.workspace }}/build.aab --non-interactive - name: Setup Version if: github.event.inputs.release == 'true' id: version uses: ./.github/actions/setup-version with: type: "mobile" - name: Prepare Release Notes if: github.event.inputs.release == 'true' id: release_notes run: | version="${{ steps.version.outputs.APP_VERSION }}" changelog_file="apps/mobile/changelog/${version}.md" release_notes_file="$RUNNER_TEMP/mobile-release-notes.md" if [ -f "$changelog_file" ]; then cp "$changelog_file" "$release_notes_file" else { echo "# What's New in v${version}" echo echo "- No changelog file found at ${changelog_file}." } > "$release_notes_file" fi echo "body_path=${release_notes_file}" >> "$GITHUB_OUTPUT" - name: Create Release Draft if: github.event.inputs.release == 'true' uses: softprops/action-gh-release@v2 with: name: Mobile v${{ steps.version.outputs.APP_VERSION }} draft: true prerelease: true tag_name: mobile/v${{ steps.version.outputs.APP_VERSION }} body_path: ${{ steps.release_notes.outputs.body_path }} # .aab cannot be installed directly on your Android Emulator or device. files: ${{ github.workspace }}/build.apk ================================================ FILE: .github/workflows/build-desktop.yml ================================================ name: 🖥️ Build Desktop on: push: branches: - "**" paths: - "apps/desktop/**" - "packages/**" - "pnpm-lock.yaml" - ".github/workflows/build-desktop.yml" workflow_dispatch: inputs: tag_version: type: boolean description: "Tag Version" store: type: boolean description: "Build for Mac App Store and Microsoft Store" build_version: type: string description: "Build Version, only available when mas is true" # https://docs.github.com/en/enterprise-cloud@latest/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs concurrency: 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' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} env: VITE_WEB_URL: ${{ vars.VITE_WEB_URL }} VITE_API_URL: ${{ vars.VITE_API_URL }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 jobs: release: if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(desktop):')) runs-on: ${{ matrix.os }} env: PROD: ${{ github.event.inputs.tag_version == 'true' || github.ref_type == 'tag' || github.event.inputs.store == 'true' }} RELEASE: ${{ github.event.inputs.tag_version == 'true' || github.ref_type == 'tag' }} strategy: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] exclude: - os: ${{ github.event.inputs.store == 'true' && 'ubuntu-latest' }} permissions: id-token: write contents: write attestations: write steps: - name: Check out Git repository Fully uses: actions/checkout@v6 if: env.PROD == 'true' with: fetch-depth: 0 lfs: true - name: Check out Git repository uses: actions/checkout@v6 if: env.PROD == 'false' with: fetch-depth: 1 lfs: true - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Use Node.js uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" - name: Install Python setuptools if: runner.os == 'macOS' run: brew install python-setuptools - name: Install appdmg if: runner.os == 'macOS' run: pnpm add -g appdmg - name: Install the Apple certificate and provisioning profile env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} BUILD_CERTIFICATE_MAS_BASE64: ${{ secrets.BUILD_CERTIFICATE_MAS_BASE64 }} BUILD_CERTIFICATE_MASPKG_BASE64: ${{ secrets.BUILD_CERTIFICATE_MASPKG_BASE64 }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }} BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} if: runner.os == 'macOS' && env.BUILD_CERTIFICATE_BASE64 != '' && env.BUILD_CERTIFICATE_MAS_BASE64 != '' && env.BUILD_CERTIFICATE_MASPKG_BASE64 != '' && env.BUILD_PROVISION_PROFILE_BASE64 != '' run: | # create variables CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 CERTIFICATE_MAS_PATH=$RUNNER_TEMP/build_certificate_mas.p12 CERTIFICATE_MASPKG_PATH=$RUNNER_TEMP/build_certificate_maspkg.p12 PP_PATH=$RUNNER_TEMP/build_pp.provisionprofile KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db # import certificate and provisioning profile from secrets echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH echo -n "$BUILD_CERTIFICATE_MAS_BASE64" | base64 --decode -o $CERTIFICATE_MAS_PATH echo -n "$BUILD_CERTIFICATE_MASPKG_BASE64" | base64 --decode -o $CERTIFICATE_MASPKG_PATH echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH # create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # import certificate to keychain security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security import $CERTIFICATE_MAS_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security import $CERTIFICATE_MASPKG_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH security find-identity $KEYCHAIN_PATH # apply provisioning profile mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles - name: Install dependencies run: pnpm i - name: Prebuild packages (Windows) if: runner.os == 'windows' run: pnpm run build:packages - name: Update main hash working-directory: apps/desktop run: pnpm update:main-hash - name: Build - Vite working-directory: apps/desktop run: pnpm build:electron-vite ${{ env.PROD == 'false' && '--mode staging' || '' }} - name: Build - Windows and Linux if: runner.os == 'Linux' || (runner.os == 'Windows' && github.event.inputs.store != 'true') working-directory: apps/desktop run: pnpm build:electron-forge ${{ env.PROD == 'false' && '--mode=staging' || '' }} - name: Build - macOS if: runner.os == 'macOS' && github.event.inputs.store != 'true' env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} OSX_SIGN_KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY }} uses: nick-fields/retry@v3 with: max_attempts: 3 timeout_minutes: 10 command: | cd apps/desktop npx electron-forge make --arch=x64 --platform=darwin ${{ env.PROD == 'false' && '--mode=staging' || '' }} npx electron-forge make --arch=arm64 --platform=darwin ${{ env.PROD == 'false' && '--mode=staging' || '' }} npx tsx scripts/merge-yml.ts - name: Build - Mac App Store if: runner.os == 'macOS' && github.event.inputs.store == 'true' env: OSX_SIGN_KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY_MAS }} OSX_SIGN_PROVISIONING_PROFILE_PATH: ${{ runner.temp }}/build_pp.provisionprofile BUILD_VERSION: ${{ github.event.inputs.build_version }} working-directory: apps/desktop run: pnpm build:electron-forge:mas - name: Build - Microsoft Store if: runner.os == 'Windows' && github.event.inputs.store == 'true' working-directory: apps/desktop run: pnpm build:electron-forge:ms - name: Build - Renderer if: runner.os == 'Linux' working-directory: apps/desktop run: pnpm build:render - name: Upload file (macos-arm64-dmg) uses: actions/upload-artifact@v7 if: runner.os == 'macOS' with: name: macos-arm64-dmg path: | apps/desktop/out/make/**/*arm64.dmg apps/desktop/out/make/**/latest-mac.yml retention-days: 90 - name: Upload file (macos-x64-dmg) uses: actions/upload-artifact@v7 if: runner.os == 'macOS' with: name: macos-x64-dmg path: | apps/desktop/out/make/**/*x64.dmg apps/desktop/out/make/**/latest-mac.yml retention-days: 90 - name: Upload file (macos-mas-pkg) uses: actions/upload-artifact@v7 if: runner.os == 'macOS' with: name: macos-mas-pkg path: | apps/desktop/out/make/**/*.pkg retention-days: 90 - name: Upload file (windows-x64-exe unsigned) uses: actions/upload-artifact@v7 id: upload-unsigned-windows-x64-exe if: runner.os == 'windows' && github.event.inputs.store != 'true' with: name: windows-x64-exe path: | apps/desktop/out/make/**/*x64.exe apps/desktop/out/make/**/latest.yml retention-days: 90 - uses: signpath/github-action-submit-signing-request@v2.0 continue-on-error: true if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true' with: api-token: "${{ secrets.SIGNPATH_API_TOKEN }}" organization-id: "8c651516-fdaf-40a1-9fea-001dffde850e" project-slug: "Folo" signing-policy-slug: "release-signing" artifact-configuration-slug: "github" github-artifact-id: "${{ steps.upload-unsigned-windows-x64-exe.outputs.artifact-id }}" output-artifact-directory: "apps/desktop/out/make/" - name: Update latest.yml if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true' run: npx tsx apps/desktop/scripts/update-windows-yml.ts - name: Upload file (windows-x64-exe signed) uses: actions/upload-artifact@v7 if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true' with: name: windows-x64-exe path: | apps/desktop/out/make/**/*x64.exe apps/desktop/out/make/**/latest.yml retention-days: 90 overwrite: true - name: Upload file (windows-x64-appx) uses: actions/upload-artifact@v7 if: runner.os == 'windows' with: name: windows-x64-appx path: | apps/desktop/out/make/**/*.appx retention-days: 90 - name: Upload file (linux-x64-appimage) uses: actions/upload-artifact@v7 if: runner.os == 'linux' with: name: linux-x64-appimage path: | apps/desktop/out/make/**/*x64.AppImage apps/desktop/out/make/**/latest-linux.yml retention-days: 90 - name: Generate artifact attestation if: env.RELEASE == 'true' continue-on-error: true uses: actions/attest-build-provenance@v4 with: subject-path: | apps/desktop/out/make/**/Folo-*.dmg apps/desktop/out/make/**/Folo-*.zip apps/desktop/out/make/**/Folo-*.exe apps/desktop/out/make/**/Folo-*.AppImage apps/desktop/out/make/**/*.yml apps/desktop/dist/manifest.yml apps/desktop/dist/*.tar.gz - run: npx changelogithub if: env.RELEASE == 'true' continue-on-error: true env: GITHUB_TOKEN: ${{ github.token }} - name: Setup Version if: env.RELEASE == 'true' id: version uses: ./.github/actions/setup-version with: type: "desktop" - name: Prepare Release Notes if: env.RELEASE == 'true' id: release_notes shell: bash run: | version="${{ steps.version.outputs.APP_VERSION }}" changelog_file="apps/desktop/changelog/${version}.md" release_notes_file="$RUNNER_TEMP/desktop-release-notes.md" if [ -f "$changelog_file" ]; then cp "$changelog_file" "$release_notes_file" else { echo "# What's New in v${version}" echo echo "- No changelog file found at ${changelog_file}." } > "$release_notes_file" fi echo "body_path=${release_notes_file}" >> "$GITHUB_OUTPUT" - name: Create Release Draft if: env.RELEASE == 'true' uses: softprops/action-gh-release@v2 with: name: Desktop v${{ steps.version.outputs.APP_VERSION }} draft: false prerelease: true tag_name: desktop/v${{ steps.version.outputs.APP_VERSION }} body_path: ${{ steps.release_notes.outputs.body_path }} files: | apps/desktop/out/make/**/Folo-*.dmg apps/desktop/out/make/**/Folo-*.zip apps/desktop/out/make/**/Folo-*.exe apps/desktop/out/make/**/Folo-*.AppImage apps/desktop/out/make/**/*.yml apps/desktop/dist/manifest.yml apps/desktop/dist/*.tar.gz ================================================ FILE: .github/workflows/build-ios-development.yml ================================================ name: 📱 Build iOS for development on: push: branches: - "**" paths: - "apps/mobile/web-app/**" - "apps/mobile/native/**" - "apps/mobile/package.json" - "apps/mobile/app.config.ts" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check-runner: runs-on: ubuntu-latest outputs: runner-label: ${{ steps.set-runner.outputs.runner-label }} steps: - name: Set runner id: set-runner run: | 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") available=$(echo "$runners" | jq '.runners[]? | select(.status == "online" and .busy == false and .labels[] .name == "macOS")') if [ -n "$available" ]; then echo "runner-label=self-hosted" >> $GITHUB_OUTPUT else echo "runner-label=macos-latest" >> $GITHUB_OUTPUT fi build-ipa-device-self-hosted: name: Build iOS IPA for device (self-hosted) if: github.secret_source != 'None' && needs.check-runner.outputs.runner-label == 'self-hosted' needs: check-runner runs-on: [self-hosted, macOS] steps: - name: 📦 Checkout code uses: actions/checkout@v6 - name: 📱 Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies run: pnpm install - name: 🔨 Build iOS IPA working-directory: apps/mobile run: eas build --platform ios --profile development --non-interactive --local --output=./build.ipa env: SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }} CI: true # Optional: Upload artifact - name: 📤 Upload IPA uses: actions/upload-artifact@v7 with: name: app-ios-development-device path: apps/mobile/build.ipa retention-days: 90 - name: Clear Xcode cache run: | rm -rf ~/Library/Developer/Xcode/DerivedData build-ipa-device-github: name: Build iOS IPA for device (GitHub-hosted) if: github.secret_source != 'None' && needs.check-runner.outputs.runner-label == 'macos-latest' needs: check-runner runs-on: macos-latest steps: - name: 📦 Checkout code uses: actions/checkout@v6 - name: 🔧 Setup Xcode uses: ./.github/actions/setup-xcode - name: 📦 Setup pnpm uses: pnpm/action-setup@v4 - name: 🏗 Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" - name: 📱 Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies run: pnpm install - name: 🔨 Build iOS IPA working-directory: apps/mobile run: eas build --platform ios --profile development --non-interactive --local --output=./build.ipa env: CI: true SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }} # Optional: Upload artifact - name: 📤 Upload IPA uses: actions/upload-artifact@v7 with: name: app-ios-development-device path: apps/mobile/build.ipa retention-days: 90 build-simulator: name: Build iOS IPA for simulator if: github.secret_source != 'None' runs-on: macos-latest steps: - name: 📦 Checkout code uses: actions/checkout@v6 - name: 🔧 Setup Xcode uses: ./.github/actions/setup-xcode - name: 📦 Setup pnpm uses: pnpm/action-setup@v4 - name: 🏗 Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" - name: 📱 Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies run: pnpm install - name: 🔨 Build iOS IPA working-directory: apps/mobile run: eas build --platform ios --profile ios-simulator --non-interactive --local --output=./build-simulator.ipa env: CI: true SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }} # Optional: Upload artifact - name: 📤 Upload IPA uses: actions/upload-artifact@v7 with: name: app-ios-development-simulator path: apps/mobile/build-simulator.ipa retention-days: 90 ================================================ FILE: .github/workflows/build-ios.yml ================================================ name: 🍎 Build iOS on: push: branches: - "**" paths: - "apps/mobile/**" - "pnpm-lock.yaml" workflow_dispatch: inputs: profile: type: choice default: preview options: - preview - production description: "Build profile" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }} cancel-in-progress: true jobs: check-runner: if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):')) runs-on: ubuntu-latest outputs: runner-label: ${{ steps.set-runner.outputs.runner-label }} steps: - name: Set runner id: set-runner run: | 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") available=$(echo "$runners" | jq '.runners[]? | select(.status == "online" and .busy == false and .labels[] .name == "macOS")') if [ -n "$available" ]; then echo "runner-label=self-hosted" >> $GITHUB_OUTPUT else echo "runner-label=macos-latest" >> $GITHUB_OUTPUT fi build-ipa-self-hosted: name: Build iOS IPA (self-hosted) if: needs.check-runner.outputs.runner-label == 'self-hosted' needs: check-runner runs-on: [self-hosted, macOS] steps: - name: 📦 Checkout code uses: actions/checkout@v6 - name: 📱 Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies run: pnpm install - name: 🔨 Build iOS IPA working-directory: apps/mobile run: eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive --local --output=./build.ipa env: SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }} CI: true # Optional: Upload artifact - name: 📤 Upload IPA uses: actions/upload-artifact@v7 with: name: app-ios path: apps/mobile/build.ipa retention-days: 90 - name: Clear Xcode cache run: | rm -rf ~/Library/Developer/Xcode/DerivedData - name: Submit to App Store if: github.event.inputs.profile == 'production' working-directory: apps/mobile run: eas submit --platform ios --path build.ipa --non-interactive build-ipa-github: name: Build iOS IPA (GitHub) if: needs.check-runner.outputs.runner-label == 'macos-latest' needs: check-runner runs-on: macos-latest steps: - name: 📦 Checkout code uses: actions/checkout@v6 - name: 🔧 Setup Xcode uses: ./.github/actions/setup-xcode - name: 📦 Setup pnpm uses: pnpm/action-setup@v4 - name: 🏗 Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" - name: 📱 Setup EAS uses: expo/expo-github-action@v8 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies run: pnpm install - name: 🔨 Build iOS IPA working-directory: apps/mobile run: eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive --local --output=./build.ipa env: CI: true SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }} # Optional: Upload artifact - name: 📤 Upload IPA uses: actions/upload-artifact@v7 with: name: app-ios path: apps/mobile/build.ipa retention-days: 90 - name: Submit to App Store if: github.event.inputs.profile == 'production' working-directory: apps/mobile run: eas submit --platform ios --path build.ipa --non-interactive ================================================ FILE: .github/workflows/build-web.yml ================================================ on: pull_request: push: branches: [main, dev] name: 🌐 CI Build web and SSR server concurrency: group: ${{ github.workflow }}-${{ github.ref }}-ssr cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} jobs: build: name: Build web and SSR server runs-on: ubuntu-latest strategy: matrix: node-version: [lts/*] steps: - name: Checkout code uses: actions/checkout@v6 with: lfs: true - name: Cache turbo build setup uses: actions/cache@v5 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- - name: Checkout LFS objects run: git lfs checkout - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: "pnpm" - name: Install dependencies run: pnpm install - name: Build web and SSR server run: | npm exec turbo run Folo#build:web @follow/ssr#build ================================================ FILE: .github/workflows/deploy-cloudflare-desktop.yml ================================================ name: "\u2601\ufe0f Deploy Desktop to Cloudflare" on: push: branches: [main, dev] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: deploy: name: Build & Deploy Desktop Web runs-on: ubuntu-latest env: VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} steps: - name: Checkout code uses: actions/checkout@v6 with: lfs: true - name: Checkout LFS objects run: git lfs checkout - name: Cache turbo build setup uses: actions/cache@v5 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- - uses: pnpm/action-setup@v4 - name: Use Node.js LTS uses: actions/setup-node@v6 with: node-version: lts/* cache: "pnpm" - name: Install dependencies run: pnpm install - name: Build desktop web (SPA) working-directory: apps/desktop env: VITE_WEB_URL: ${{ github.ref == 'refs/heads/dev' && 'https://dev.folo.is' || 'https://app.folo.is' }} VITE_API_URL: ${{ github.ref == 'refs/heads/dev' && 'https://api.dev.folo.is' || 'https://api.folo.is' }} run: pnpm run build:web - name: Deploy desktop web to Cloudflare (dev) if: github.ref == 'refs/heads/dev' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/desktop command: deploy --env dev - name: Deploy desktop web to Cloudflare (prod) if: github.ref == 'refs/heads/main' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/desktop command: 'deploy --env=""' ================================================ FILE: .github/workflows/deploy-cloudflare-landing.yml ================================================ name: "\u2601\ufe0f Deploy Landing to Cloudflare" on: push: branches: [main, dev] workflow_dispatch: inputs: confirm_production_deploy: description: Deploy the selected branch to production (folo.is) required: true default: false type: boolean concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }} cancel-in-progress: true jobs: deploy: name: Build & Deploy Landing Worker runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 with: lfs: true - name: Checkout LFS objects run: git lfs checkout - name: Cache turbo build setup uses: actions/cache@v5 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- - uses: pnpm/action-setup@v4 - name: Use Node.js LTS uses: actions/setup-node@v6 with: node-version: lts/* cache: "pnpm" - name: Install dependencies run: pnpm install - name: Resolve deployment target id: target run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then if [[ "${{ inputs.confirm_production_deploy }}" != "true" ]]; then echo "Manual production deploy was not confirmed." >&2 exit 1 fi echo "environment=prod" >> "$GITHUB_OUTPUT" exit 0 fi if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then echo "environment=dev" >> "$GITHUB_OUTPUT" exit 0 fi echo "environment=prod" >> "$GITHUB_OUTPUT" - name: Build Landing Worker run: pnpm exec turbo run @follow/landing#cf:build - name: Deploy Landing to Cloudflare (dev) if: steps.target.outputs.environment == 'dev' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/landing command: deploy --env dev --name landing-next-dev --routes landing.dev.folo.is/* - name: Deploy Landing to Cloudflare (prod) if: steps.target.outputs.environment == 'prod' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/landing command: deploy ================================================ FILE: .github/workflows/deploy-cloudflare-ssr.yml ================================================ name: "\u2601\ufe0f Deploy SSR to Cloudflare" on: push: branches: [main, dev] workflow_dispatch: inputs: confirm_production_deploy: description: Deploy the selected branch to production (app.folo.is) required: true default: false type: boolean concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }} cancel-in-progress: true jobs: deploy: name: Build & Deploy SSR Worker runs-on: ubuntu-latest env: VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} steps: - name: Checkout code uses: actions/checkout@v6 with: lfs: true - name: Checkout LFS objects run: git lfs checkout - name: Cache turbo build setup uses: actions/cache@v5 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- - uses: pnpm/action-setup@v4 - name: Use Node.js LTS uses: actions/setup-node@v6 with: node-version: lts/* cache: "pnpm" - name: Install dependencies run: pnpm install - name: Resolve deployment target id: target run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then if [[ "${{ inputs.confirm_production_deploy }}" != "true" ]]; then echo "Manual production deploy was not confirmed." >&2 exit 1 fi echo "environment=prod" >> "$GITHUB_OUTPUT" echo "vite_web_url=https://app.folo.is" >> "$GITHUB_OUTPUT" echo "vite_api_url=https://api.folo.is" >> "$GITHUB_OUTPUT" exit 0 fi if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then echo "environment=dev" >> "$GITHUB_OUTPUT" echo "vite_web_url=https://dev.folo.is" >> "$GITHUB_OUTPUT" echo "vite_api_url=https://api.dev.folo.is" >> "$GITHUB_OUTPUT" exit 0 fi echo "environment=prod" >> "$GITHUB_OUTPUT" echo "vite_web_url=https://app.folo.is" >> "$GITHUB_OUTPUT" echo "vite_api_url=https://api.folo.is" >> "$GITHUB_OUTPUT" - name: Build desktop web (SSR assets) working-directory: apps/desktop env: VITE_WEB_URL: ${{ steps.target.outputs.vite_web_url }} VITE_API_URL: ${{ steps.target.outputs.vite_api_url }} run: pnpm run build:web - name: Build SSR Worker working-directory: apps/ssr run: pnpm run build:worker - name: Copy WASM file run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm - name: Deploy SSR Worker to Cloudflare (dev) if: steps.target.outputs.environment == 'dev' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/ssr command: deploy --env dev - name: Deploy SSR Worker to Cloudflare (prod) if: steps.target.outputs.environment == 'prod' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/ssr command: 'deploy --env=""' ================================================ FILE: .github/workflows/issue-labeler.yml ================================================ name: 🏷️ Issue labeler on: issues: types: [opened] permissions: contents: read jobs: label-component: runs-on: ubuntu-latest permissions: # required for all workflows issues: write # only required for workflows in private repositories actions: read contents: read steps: - uses: actions/checkout@v6 - name: Parse issue form uses: stefanbuck/github-issue-parser@v3 id: issue-parser with: template-path: .github/ISSUE_TEMPLATE/bug_report.yml - name: Set labels based on platform field uses: redhat-plumbers-in-action/advanced-issue-labeler@v3 with: issue-form: ${{ steps.issue-parser.outputs.jsonString }} token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/lint.yml ================================================ on: pull_request: push: branches: [main, dev] # Do not use secrets or variables to allow for public access env: VITE_WEB_URL: https://app.folo.is VITE_API_URL: https://api.follow.is name: ✨ CI Format, Typecheck and Lint concurrency: group: ${{ github.workflow }}-${{ github.ref }}-lint cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} jobs: build: name: Format, Lint and Typecheck runs-on: ubuntu-latest strategy: matrix: node-version: [lts/*] steps: - name: Checkout code uses: actions/checkout@v6 with: lfs: true - name: Cache turbo build setup uses: actions/cache@v5 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- - name: Checkout LFS objects run: git lfs checkout - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: "pnpm" - name: Install dependencies run: pnpm install - name: Build web and SSR server run: | npm exec turbo run Folo#build:web @follow/ssr#build - name: Format, Lint and Typecheck run: | export NODE_OPTIONS="--max_old_space_size=16384" npm exec turbo run format:check typecheck lint - name: Run test run: npm exec turbo run test ================================================ FILE: .github/workflows/pr-title-check.yml ================================================ name: ✅ PR Conventional Commit Validation on: pull_request: types: [opened, synchronize, reopened, edited] jobs: validate-pr-title: runs-on: ubuntu-latest steps: - name: PR Conventional Commit Validation uses: ytanikin/PRConventionalCommits@1.3.0 with: task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert","release","build"]' add_label: false ================================================ FILE: .github/workflows/similar-issues.yml ================================================ name: Similar Issues via AI MCP on: issues: types: [opened] jobs: find-similar: permissions: contents: read issues: write models: read runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v6 - name: Prepare prompt variables id: prepare_input uses: actions/github-script@v8 with: script: | const issue = context.payload.issue || {}; const title = issue.title || ''; const body = issue.body || ''; const indent = ' '; // Indent subsequent lines so YAML block scalar indentation remains valid const bodyIndented = body.replace(/\n/g, '\n' + indent); core.setOutput('issue_title_json', JSON.stringify(title)); core.setOutput('issue_body_indented_json', JSON.stringify(bodyIndented)); - name: Find similar issues with AI (MCP) id: inference uses: actions/ai-inference@v2 with: prompt-file: ./.github/prompts/similar_issues.prompt.yml input: | issue_title: ${{ steps.prepare_input.outputs.issue_title_json }} issue_body: ${{ steps.prepare_input.outputs.issue_body_indented_json }} repository: ${{ github.repository }} enable-github-mcp: true token: ${{ secrets.GITHUB_TOKEN }} github-mcp-token: ${{ secrets.USER_PAT }} endpoint: https://models.github.ai/orgs/RSSNext/inference - name: Prepare comment body id: prepare uses: actions/github-script@v8 env: AI_RESPONSE: ${{ steps.inference.outputs.response }} with: script: | let data; try { data = JSON.parse(process.env.AI_RESPONSE || '{}'); } catch (e) { core.setOutput('has_matches', 'false'); return; } const matches = Array.isArray(data.matches) ? data.matches : []; const rawIssueNumber = context?.payload?.issue?.number; const issueNumber = typeof rawIssueNumber === 'string' ? Number(rawIssueNumber) : rawIssueNumber; const filteredMatches = Number.isFinite(issueNumber) ? matches.filter((m) => { const matchNumber = typeof m?.number === 'string' ? Number(m.number) : m?.number; if (Number.isFinite(matchNumber)) { return matchNumber !== issueNumber; } if (typeof m?.url === 'string') { const urlMatch = m.url.match(/\/issues\/(\d+)(?:$|[?#])/); if (urlMatch) { return Number(urlMatch[1]) !== issueNumber; } } return true; }) : matches; if (!filteredMatches.length) { core.setOutput('has_matches', 'false'); return; } const lines = []; lines.push('I found similar issues that might help:'); for (const m of filteredMatches.slice(0, 3)) { const num = m.number != null ? `#${m.number}` : ''; const title = m.title || 'Untitled'; const url = m.url || ''; const score = typeof m.similarity_score === 'number' ? ` (similarity: ${m.similarity_score.toFixed(2)})` : ''; lines.push(`- ${url}${score}`.trim()); } core.setOutput('has_matches', 'true'); core.setOutput('comment_body', lines.join('\n')); - name: Comment similar issues if: steps.prepare.outputs.has_matches == 'true' uses: actions/github-script@v8 with: script: | const body = ${{ toJson(steps.prepare.outputs.comment_body) }}; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, body }); ================================================ FILE: .github/workflows/sync.yaml ================================================ name: 🔄 Sync Release Branches To Dev on: push: branches: - main - mobile-main permissions: contents: write jobs: sync-to-dev: runs-on: ubuntu-latest if: | (github.ref == 'refs/heads/main' && contains(github.event.head_commit.message || '', 'release(desktop):')) || (github.ref == 'refs/heads/mobile-main' && contains(github.event.head_commit.message || '', 'release(mobile):')) steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Git run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Merge source branch into dev run: | source_branch="${GITHUB_REF_NAME}" git fetch origin main mobile-main dev git checkout dev git merge --no-ff "origin/${source_branch}" -m "chore(sync): merge ${source_branch} into dev" git push origin dev ================================================ FILE: .github/workflows/tag.yml ================================================ name: 🏷️ Release Orchestrator on: push: branches: - main - mobile-main permissions: contents: write actions: write jobs: create_tag: name: Create Release Tag runs-on: ubuntu-latest outputs: tag_version: ${{ steps.release_info.outputs.tag_version }} platform: ${{ steps.release_info.outputs.platform }} version: ${{ steps.release_info.outputs.version }} ref_name: ${{ steps.release_info.outputs.ref_name }} steps: - name: Checkout repository uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: lts/* - name: Make script executable run: | chmod +x .github/scripts/extract-release-info.mjs - name: Extract release information id: extract_info run: .github/scripts/extract-release-info.mjs continue-on-error: true - name: Expose release outputs id: release_info run: | echo "tag_version=${tag_version:-}" >> "$GITHUB_OUTPUT" echo "platform=${platform:-}" >> "$GITHUB_OUTPUT" echo "version=${version:-}" >> "$GITHUB_OUTPUT" echo "ref_name=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - name: Validate git configuration if: steps.release_info.outputs.tag_version != '' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Create and push tag if: steps.release_info.outputs.tag_version != '' run: | git fetch --tags --force echo "Creating tag: ${{ steps.release_info.outputs.tag_version }}" if git rev-parse "${{ steps.release_info.outputs.tag_version }}" >/dev/null 2>&1; then echo "Tag ${{ steps.release_info.outputs.tag_version }} already exists, skipping creation" exit 0 fi git tag "${{ steps.release_info.outputs.tag_version }}" git push origin "${{ steps.release_info.outputs.tag_version }}" echo "Successfully created and pushed tag: ${{ steps.release_info.outputs.tag_version }}" trigger_builds: name: Trigger Platform Builds needs: create_tag if: needs.create_tag.outputs.tag_version != '' runs-on: ubuntu-latest steps: - name: Checkout repository if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' uses: actions/checkout@v6 with: fetch-depth: 0 - name: Configure Git if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Resolve desktop build version id: desktop_build_version if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' run: | git fetch --tags --force latest_tag="$(git tag -l 'desktop-build/v*' --sort=-v:refname | head -n 1)" if [ -z "$latest_tag" ]; then last_build=110 else last_build="${latest_tag#desktop-build/v}" fi next_build=$((last_build + 1)) build_tag="desktop-build/v${next_build}" if git rev-parse "$build_tag" >/dev/null 2>&1; then echo "Build tag $build_tag already exists." exit 1 fi git tag "$build_tag" git push origin "$build_tag" echo "build_version=${next_build}" >> "$GITHUB_OUTPUT" echo "Desktop build version: ${next_build}" - name: Trigger Desktop Tag Version Build if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const response = await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'build-desktop.yml', ref: 'main', inputs: { tag_version: 'true', store: 'false' } }); console.log('Desktop Tag Version build triggered successfully'); - name: Trigger Desktop Store Build if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const response = await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'build-desktop.yml', ref: 'main', inputs: { tag_version: 'false', store: 'true', build_version: '${{ steps.desktop_build_version.outputs.build_version }}' } }); console.log('Desktop store build triggered successfully'); - name: Trigger Mobile Preview Release Build if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const response = await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'build-android.yml', ref: 'mobile-main', inputs: { profile: 'preview', release: 'true' } }); console.log('Mobile preview release build triggered successfully'); - name: Trigger Mobile Production Android Build if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const response = await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'build-android.yml', ref: 'mobile-main', inputs: { profile: 'production', release: 'false' } }); console.log('Mobile production Android build triggered successfully'); - name: Trigger Mobile Production iOS Build if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const response = await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'build-ios.yml', ref: 'mobile-main', inputs: { profile: 'production' } }); console.log('Mobile production iOS build triggered successfully'); ================================================ FILE: .github/workflows/translator.yml ================================================ name: "🌍 translator" on: issues: types: [opened, edited] issue_comment: types: [created, edited] discussion: types: [created, edited] discussion_comment: types: [created, edited] jobs: translate: permissions: issues: write discussions: write pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: IS_MODIFY_TITLE: true ================================================ FILE: .gitignore ================================================ node_modules dist out .next .open-next next-env.d.ts .DS_Store *.log* .env .eslintcache .env.* !.env.example # Sentry Config File .env.sentry-build-plugin .vercel stats.html electron.vite.config.*.mjs vite.config.*.mjs .generated .turbo apps/desktop/src/renderer/dev-dist tsconfig.tsbuildinfo buildServer.json **/**/generated-routes.ts apps/desktop/build/appxmanifest.xml apps/desktop/resources/cli .claude/settings.local.json .serena .wrangler # Local agent artifacts .codex/ # E2E outputs /apps/desktop/e2e/playwright-report/ /apps/desktop/e2e/test-results/ /apps/mobile/e2e/artifacts/ /apps/mobile/report.xml /report.xml # Mobile local E2E build artifacts apps/mobile/build-*.tar.gz ================================================ FILE: .npmrc ================================================ shamefully-hoist=true node-linker=hoisted save-exact=true ================================================ FILE: .nvmrc ================================================ stable ================================================ FILE: .prettierignore ================================================ pnpm-lock.yaml CHANGELOG.md .context apps/external/postcss.config.cjs apps/mobile/android apps/mobile/ios apps/mobile/native/example apps/mobile/native/android apps/mobile/native/ios apps/mobile/.expo generated-routes.ts ================================================ FILE: .prettierrc.mjs ================================================ /** @type {import("prettier").Config & import("prettier-plugin-tailwindcss").PluginOptions} */ export default { semi: false, singleQuote: false, printWidth: 100, tabWidth: 2, trailingComma: "all", objectWrap: "preserve", plugins: ["prettier-plugin-tailwindcss"], tailwindConfig: "./apps/desktop/tailwind.config.ts", overrides: [ { files: "apps/mobile/**/*.{css,js,jsx,ts,tsx}", options: { tailwindConfig: "./apps/mobile/tailwind.config.ts", }, }, { files: "apps/mobile/web-app/html-renderer/**/*.{css,js,jsx,ts,tsx}", options: { tailwindConfig: "./apps/mobile/web-app/html-renderer/tailwind.config.ts", }, }, { files: "apps/ssr/**/*.{css,html,js,jsx,ts,tsx}", options: { tailwindConfig: "./apps/ssr/tailwind.config.ts", }, }, ], } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "dbaeumer.vscode-eslint", "johnsoncodehk.vscode-tsslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss" ] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Debug Main Process", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" }, "runtimeArgs": ["--sourcemap"], "env": { "REMOTE_DEBUGGING_PORT": "9222" } }, { "name": "Debug Renderer Process", "port": 9222, "request": "attach", "type": "chrome", "webRoot": "${workspaceFolder}/src/renderer", "timeout": 60000, "presentation": { "hidden": true } } ], "compounds": [ { "name": "Debug All", "configurations": ["Debug Main Process", "Debug Renderer Process"], "presentation": { "order": 1 } } ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": { "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } }, "files.associations": { "*.css": "tailwindcss" }, "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], ["tw`([^`]*)`", "([^`]*)"], ["[a-zA-Z]+[cC]lass[nN]ame[\"'`]?:\\s*[\"'`]([^\"'`]*)[\"'`]", "([^\"'`]*)"], ["[a-zA-Z]+[cC]lass[nN]ame\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", "([^\"'`]*)"] ], "github.copilot.chat.codeGeneration.useInstructionFiles": true, "tailwindCSS.experimental.configFile": { "apps/mobile/tailwind.config.ts": "apps/mobile/**", "apps/ssr/tailwind.config.ts": "apps/ssr/**", "apps/desktop/tailwind.config.ts": ["!apps/mobile/**", "!apps/ssr/**", "**"] }, "typescript.tsserver.maxTsServerMemory": 8096, "typescript.tsserver.nodePath": "node", // If you do not want to autofix some rules on save // You can put this in your user settings or workspace settings "eslint.codeActionsOnSave.rules": [ "!prefer-const", "!unused-imports/no-unused-imports", "!@stylistic/jsx-self-closing-comp", "!tailwindcss/classnames-order", "!arrow-body-style", "*" ], // If you want to silent stylistic rules // You can put this in your user settings or workspace settings "eslint.rules.customizations": [ { "rule": "@stylistic/*", "severity": "off", "fixable": true }, { "rule": "tailwindcss/classnames-order", "severity": "off" }, { "rule": "antfu/consistent-list-newline", "severity": "off" }, { "rule": "hyoban/jsx-attribute-spacing", "severity": "off" }, { "rule": "simple-import-sort/*", "severity": "off" }, { "rule": "prefer-const", "severity": "off" }, { "rule": "unused-imports/no-unused-imports", "severity": "off" } ], "cSpell.words": ["Hydable", "rsshub", "Русский"], // "editor.foldingImportsByDefault": true, "commentTranslate.hover.enabled": false, "typescript.tsdk": "node_modules/typescript/lib", "i18n-ally.enabledFrameworks": ["i18next"], "i18n-ally.displayLanguage": "en", "i18n-ally.localesPaths": ["locales"], "i18n-ally.namespace": true, "i18n-ally.pathMatcher": "{namespaces}/{locale}.json", "lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB", "lldb.launch.expressions": "native", "[swift]": { "editor.defaultFormatter": "swiftlang.swift-vscode" }, "npm.exclude": "**/packages/**" } ================================================ FILE: AGENTS.md ================================================ # AGENTS.md This 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. ## Project overview - Monorepo managed by pnpm workspaces + Turbo. - Apps: - `apps/desktop` – Electron app (Vite + React renderer is the primary web app) - `apps/mobile` – React Native app via Expo - `apps/ssr` – Minimal SSR site for external sharing - Shared packages: `packages/internal` (components, atoms, hooks, store, utils, database, etc.). ## Setup commands ```bash # Install deps pnpm install # Desktop – recommended (browser renderer) cd apps/desktop && pnpm run dev:web # Desktop – full Electron cd apps/desktop && pnpm run dev:electron # Mobile – Expo cd apps/mobile && pnpm run dev # or target platforms cd apps/mobile && pnpm run ios cd apps/mobile && pnpm run android # SSR cd apps/ssr && pnpm run dev # Build web version (desktop renderer) pnpm run build:web ``` ## Quality gates (must-pass before commit/PR) ```bash # 1) Typecheck first (required) pnpm run typecheck # 2) Lint and auto-fix pnpm run lint:fix # 3) Tests pnpm run test ``` - Run the above at the root, or use per-package variants as needed. - Follow this order strictly: typecheck → lint → test. - After every modification, run the following checks to catch errors early: ```bash npm exec turbo run format:check typecheck lint npm exec turbo run test ``` ## Code style and conventions - TypeScript strict; avoid `any` (use precise types). Comments in English. Keep solutions simple and maintainable. - Prefer CSS transitions/animations for simple UI interactions. Use JS-driven motion only when necessary to avoid frame drops. - Imports: use `pathe` instead of `node:path` for cross‑platform paths. - Organize shared, reusable UI in `packages/internal/components`; app-specific UI stays in its app. - **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. ## Team preferences - Prefer CSS transitions/animations over JS-based motion for simple interactions to avoid frame drops. - Prefer simple, easy-to-maintain solutions. - Avoid using the `any` type in TypeScript. - Write code comments in English. ## Architecture quick reference - State: Jotai for atoms, Zustand for complex stores, TanStack Query for server state. - Database: Drizzle + SQLite (see `packages/internal/database`). - Error handling: custom utils in `packages/internal/utils`; Sentry integrated. - i18n: i18next with flat keys only; no `defaultValue`. Provide `en`, `zh-CN`, `ja` for each feature. Avoid conflicting dotted keys. ## UI system and design tokens ### Tailwind + Apple UIKit colors (Desktop/Web) - Use Tailwind classes bound to Apple UIKit color tokens (light/dark adaptive). Prefix by CSS property: - System colors: `text-red`, `bg-blue`, `border-gray`, etc. for `red|orange|yellow|green|mint|teal|cyan|blue|indigo|purple|pink|brown|gray`. - Fill: `bg-fill[-secondary|-tertiary|-quaternary|-quinary]` and `bg-fill-vibrant[-secondary|-tertiary|-quaternary|-quinary]` (and `border-*` as needed). - Text: `text-text`, `text-text-secondary|tertiary|quaternary|quinary`, `text-text-vibrant(-secondary|-tertiary|-quaternary|-quinary)`. - Material: `bg-material-ultra-thick|thick|medium|thin|ultra-thin|opaque`. - Control: `bg-control-enabled|disabled`. - Interface: `bg-menu|popover|titlebar|sidebar|selection-focused|selection-focused-fill|selection-unfocused|selection-unfocused-fill|header-view|tooltip|under-window-background`. These classes map to the UIKit color variables (see `.cursor rules/color` and `apps/desktop/AGENTS.md`). ### Icons (Desktop/Web) - Prefer MingCute with `i-mgc-` prefix (e.g., `i-mgc-copy-cute-re`). Use `i-mingcute-` only if no `i-mgc-` exists. ### Motion (Desktop/Web) - Use Framer Motion with LazyMotion via `m` from `motion/react` (e.g., `m.div`). - Prefer spring presets from `@follow/components/constants/spring.js` (`Spring.presets.smooth|snappy|bouncy`). - For simple micro-interactions, prefer CSS transitions first. ### React Native (Mobile) - Styling: NativewindCSS; do not use external `StyleSheet.create` for new UI. - Icons: use `@/apps/mobile/icons` only. - Colors: use React Native UIKit color system (via `react-native-uikit-colors`) with Tailwind utilities: - Backgrounds: `system-background`, `secondary-system-background`, `tertiary-system-background`. - Grouped backgrounds: `system-grouped-background`, `secondary-system-grouped-background`, `tertiary-system-grouped-background`. - Labels: `label`, `secondary-label`, `tertiary-label`, `quaternary-label`. - Fills: `system-fill`, `secondary-system-fill`, `tertiary-system-fill`, `quaternary-system-fill`. - Separators: `separator`, `opaque-separator`, `non-opaque-separator`. - Semantic colors: `red|orange|yellow|green|mint|teal|cyan|blue|indigo|purple|pink|brown`, grays `gray..gray6`, and interactive `link`, `placeholder-text`. ## Component placement 1. Check existing components in `apps/desktop/layer/renderer/src/modules/renderer/components` for app-specific UI. 2. If generic and reusable, implement in `packages/internal/components` and export from the package index. ## Testing & CI tips - Use Vitest for unit tests; co-locate tests near source files. - After moving files or changing imports, run `pnpm lint` and `pnpm typecheck` for the affected package. - CI expects `pnpm typecheck`, `pnpm lint`, and `pnpm test` to pass before merge. ## Agent workflow (Cursor-oriented improvements) - Status updates: provide brief progress notes when running tool batches. - Prefer semantic code search to explore unfamiliar areas; use exact grep only for symbols. - Default to parallelizing independent searches/reads to reduce latency. - Avoid multi-line speculative edits; keep edits minimal and targeted; preserve existing indentation. - When editing TypeScript, do not introduce `any`; keep types precise. - For UI, prefer CSS transitions for simple effects; use Framer Motion `m.*` only when needed. ## Context7 (up-to-date docs) - Use Context7 to fetch current library docs before using APIs prone to change. - Workflow: 1. Resolve a library ID: resolve the library (e.g., React Native, Vite, TanStack Query). 2. Fetch docs scoped to the topic (e.g., hooks, routing). 3. Integrate code examples following our style rules. ## Sequential Thinking (step-by-step problem solving) - Break work into small thought steps: 1. Define immediate goal/assumption. 2. Use suitable tool (search, code edit, error explainer, docs). 3. Record the output/results. 4. Decide next step or branch alternatives; compare trade-offs. - Encourage rollback/iteration if new information contradicts prior steps. ## Subproject guides - 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. ## Quick checklists - Implementation - [ ] Is code placed in the right package/app? - [ ] Type-safe (no `any`), readable names, English comments where needed. - [ ] Uses correct UIKit Tailwind tokens and icon sources. - [ ] For motion: CSS first; `m.*` only if necessary. - Validation - [ ] `pnpm typecheck` passes - [ ] `pnpm lint:fix` passes cleanly - [ ] Tests updated and pass ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at follow@rss3.io. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Folo Thank you for considering contributing to Folo! We welcome contributions from the community to help improve and expand the project. ## Getting Started Before 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`. ```sh corepack enable && corepack prepare ``` ### Installing Dependencies To install the necessary dependencies, run: ```sh pnpm install ``` ## Development Setup ### Develop in the Browser For a more convenient development experience, we recommend developing in the browser: ```sh cd apps/desktop && pnpm run dev:web ``` This will open the browser at `https://app.folo.is/__debug_proxy`, allowing you to access the online API environment for development and debugging. ### Develop in Electron If you prefer to develop in Electron, follow these steps: 0. Go to the `apps/desktop` directory: ```sh cd apps/desktop ``` 1. Copy the example environment variables file: ```sh cp .env.example .env ``` 2. Set `VITE_API_URL` to `https://api.follow.is` in your `.env` file. 3. Run the development server: ```sh pnpm run dev:electron ``` > **Tip:** If you encounter login issues, copy the `__Secure-better-auth.session_token` from your browser's cookies into the app. ### Develop in External SSR Web App To develop in SSR, follow these steps: 1. Go to the `apps/ssr` directory: ```sh cd apps/ssr ``` 2. Run the development server: ```sh pnpm run dev ``` ### Develop in Mobile App To develop in the mobile app, follow these steps: > [!NOTE] > You need to have a Mac device to develop in the mobile app. > > And already installed Xcode and the necessary dependencies. 1. Go to the `apps/mobile` directory: ```sh cd apps/mobile ``` 2. Copy the example environment variables file: ```sh cp .env.example .env ``` Then set the required environment variables in your `.env` file: ```sh echo 'EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN="xxx"' >> .env ``` Or manually edit the `.env` file to add: ``` EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN="xxx" ``` the value is any string. 3. Build and install Folo(dev) app from source: (This step will take a while and only need to be done once) ```sh pnpm expo prebuild --clean # Optional pnpm run ios ``` 4. Run the development server: ```sh pnpm run dev ``` #### Development Native Modules To develop native iOS modules, follow these steps: 1. Go to the `apps/mobile` directory: ```sh cd apps/mobile/ios ``` 2. Open project in Xcode: ```sh open Folo.xcworkspace ``` 3. Open `Pods` in left sidebar and select `FollowNative`: ![](https://github.com/user-attachments/assets/a449c087-6d55-4cbd-bc4b-c61a08406e98) 4. Build and run the project. ## Contribution Guidelines - Ensure your code follows the project's coding standards and conventions. - Write clear, concise commit messages. - Include relevant tests for your changes. - Update documentation as necessary. ## Community Join our community to discuss ideas, ask questions, and share your contributions: - [Discord](https://discord.gg/AwWcAQ7euc) - [Twitter](https://x.com/intent/follow?screen_name=folo_is) We look forward to your contributions! ## License By 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`. ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . --- Folo is licensed under the GNU Affero General Public License version 3 with the addition of the following special exception: All content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed. ================================================ FILE: README.md ================================================
Logo

Folo

   




Folo Mobile Folo Desktop

As 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. ## 👋🏻 Getting Started & Join Our Community Whether 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. Feel free to try it using the following methods: | Operating System | Source | | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Any | Browser | | iOS | App Store | | Android | Google Play App Store | | macOS | Microsoft Store App Store | | Windows | Microsoft Store App Store | | Linux | App Store | You can also install using the following methods maintained by our community: - 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). - 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). - 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). - 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). | [![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. | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- | | [![](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. | > \[!IMPORTANT] > > **Star Us**, You will receive all release notifications from GitHub without any delay \~ ![Image](https://github.com/user-attachments/assets/a08f9437-b24c-4388-8f01-2826e09eeaf2) Performance Stats of RSSNext/Folo - Last 28 days ## ✨ Features ### Customized Information Hub Subscribe to a vast range of feeds and curated lists. Curate your favorites and keep track of what matters most to you. ![](https://github.com/user-attachments/assets/11dc7d21-f5d8-4e41-9269-24fc352aa02b) ### AI At Your Fingertips A smarter and more efficient browsing with AI-powered features like translation, summary, and more. ![](https://github.com/user-attachments/assets/37cf4f2f-4c5e-4775-86e8-2fa1a1b2ecf5) ### Dynamic Content Support Because we know content is more than just text. From articles to videos, images to audio — Folo gets it all covered. ![](https://github.com/user-attachments/assets/d1379fd6-8767-476e-b0dc-d61753715e26) ### More Than Just An App This isn’t just another app. Folo is a community — introducing a new era of openness and community-driven experience. ![](https://github.com/user-attachments/assets/62004a04-eaea-4f5d-bfbf-4e68b6b90286) ## 🤝 Contributing You are welcome to join the open source community to build together, please check our [Contributing Guide](./CONTRIBUTING.md) for more details. ## 🔏 Code signing policy Folo for Windows uses free code signing provided by [SignPath.io](https://about.signpath.io/), a certificate by [SignPath Foundation](https://signpath.org/). Folo for macOS and iOS is signed and notarized by [Apple Developer Program](https://developer.apple.com/programs/). All released files are verified with [GitHub artifact attestations](https://github.com/RSSNext/Folo/attestations) to ensure their provenance and integrity. ## 📝 License Folo is licensed under the GNU Affero General Public License version 3 with the addition of the following special exception: All content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions We always recommend using the latest version of Follow to ensure you get all security updates. ## Reporting a Vulnerability Please report security vulnerabilities to follow@rss3.io. ================================================ FILE: api/vercel_webhook.ts ================================================ import crypto from "node:crypto" import type { VercelRequest, VercelResponse } from "@vercel/node" import getRawBody from "raw-body" export default async function handler(request: VercelRequest, response: VercelResponse) { const { WEBHOOK_SECRET: INTEGRATION_SECRET } = process.env if (typeof INTEGRATION_SECRET != "string") { return response.status(400).json({ code: "invalid_secret", error: "No integration secret found", }) } const rawBody = await getRawBody(request) const bodySignature = sha1(rawBody, INTEGRATION_SECRET) if (bodySignature !== request.headers["x-vercel-signature"]) { return response.status(403).json({ code: "invalid_signature", error: "signature didn't match", }) } const json = JSON.parse(rawBody.toString("utf-8")) switch (json.type) { // https://vercel.com/docs/observability/webhooks-overview/webhooks-api#deployment.succeeded case "deployment.succeeded": { const { target } = json.payload || json.data if (target === "production") { await purgeCloudflareCache() } else { console.info(`Skipping non-production deployment: ${target}`, json) } } // ... } return response.status(200).end("OK") } function sha1(data: Buffer, secret: string): string { return crypto.createHmac("sha1", secret).update(data).digest("hex") } export const config = { api: { bodyParser: false, }, } async function purgeCloudflareCache() { const { CF_TOKEN, CF_ZONE_ID } = process.env if (typeof CF_TOKEN !== "string" || typeof CF_ZONE_ID !== "string") { throw new TypeError("No Cloudflare token or zone ID found") } const apiUrl = `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache` const manifestPath = await fetch(`https://app.folo.is/assets/manifest.txt?t=${Date.now()}`).then( (res) => res.text(), ) try { await fetch(apiUrl, { method: "POST", headers: { Authorization: CF_TOKEN, }, body: JSON.stringify({ tags: ["follow-assets"], }), }) console.info("Successfully purged Cloudflare cache") } catch { console.error("Failed to purge Cloudflare cache by tags, fallback to purge by files") const allPath = manifestPath.split("\n").map((path) => `https://app.folo.is/${path}`) // Function to delay execution const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const taskPromise = [] as Promise[] // Batch processing for (let i = 0; i < allPath.length; i += 30) { const batch = allPath.slice(i, i + 30) const r = fetch(apiUrl, { method: "POST", headers: { Authorization: CF_TOKEN, }, body: JSON.stringify({ files: batch, }), }) taskPromise.push(r) // Delay for 0.5 seconds between batches if (i + 30 < allPath.length) { await delay(500) } } const result = await Promise.allSettled(taskPromise) console.info(`Success: ${result.filter((r) => r.status === "fulfilled").length}`) console.info(`Failed: ${result.filter((r) => r.status === "rejected").length}`) } } ================================================ FILE: apps/cli/package.json ================================================ { "name": "folocli", "type": "module", "version": "0.0.1", "description": "Folo CLI for terminal workflows and automation", "author": "Folo Team", "license": "AGPL-3.0-only", "homepage": "https://github.com/RSSNext/Folo", "repository": { "type": "git", "url": "git+https://github.com/RSSNext/Folo.git" }, "keywords": [ "folo", "rss", "reader", "cli", "automation" ], "bin": { "folo": "./dist/index.js" }, "files": [ "dist", "skill.md" ], "engines": { "node": ">=18" }, "scripts": { "build": "tsup --config tsup.config.ts && chmod +x dist/index.js", "dev": "tsx src/index.ts", "start": "node dist/index.js", "test": "pnpm run build && vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { "@follow-app/client-sdk": "catalog:", "commander": "14.0.1", "pathe": "2.0.3" }, "devDependencies": { "@follow/configs": "workspace:*", "@types/node": "25.2.3", "tsup": "8.5.0", "tsx": "4.21.0", "typescript": "catalog:" } } ================================================ FILE: apps/cli/skill.md ================================================ # Folo CLI Skill ## Trigger Conditions Use this skill when a user asks to: - Manage RSS subscriptions - Browse timeline entries - Read entry details or readability content - Mark entries as read/unread - Search feeds/lists or trending sources - Import/export OPML - Check unread counts ## Preconditions 1. Node.js and npm are installed so the CLI can be executed with `npx`. 2. Authentication is configured: - `npx --yes folocli@latest login` (recommended, opens browser and auto-logins) - or `npx --yes folocli@latest login --token ` - or set `FOLO_TOKEN=` ## Execution Policy - Prefer `npx --yes folocli@latest ...` for all agent runs. - Do not require `npm install -g folocli`. - No separate update preflight is needed. Using `folocli@latest` is the update strategy. - 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. ## Output Contract Default output is JSON with a stable envelope: ```json { "ok": true, "data": {}, "error": null } ``` Errors return: ```json { "ok": false, "data": null, "error": { "code": "UNAUTHORIZED", "message": "Token is invalid or expired." } } ``` You can switch output mode: - `--format json` (default) - `--format table` - `--format plain` ## Core Workflows ### 1. Timeline Reading 1. Fetch timeline: - `npx --yes folocli@latest timeline --limit 10` 2. Get entry detail: - `npx --yes folocli@latest entry get ` 3. Get readability content: - `npx --yes folocli@latest entry read ` ### 2. Subscription Management 1. Discover: - `npx --yes folocli@latest search discover ` 2. Add subscription: - `npx --yes folocli@latest subscription add --feed ` - or `npx --yes folocli@latest subscription add --list ` 3. List subscriptions: - `npx --yes folocli@latest subscription list` ### 3. Unread Processing 1. Check unread total: - `npx --yes folocli@latest unread count` 2. List unread subscriptions: - `npx --yes folocli@latest unread list` 3. Read unread entries: - `npx --yes folocli@latest timeline --unread-only --limit 20` 4. Mark read: - `npx --yes folocli@latest entry mark-read ` - or batch: `npx --yes folocli@latest entry mark-all-read --view articles` ### 4. Collection Operations - Add: `npx --yes folocli@latest collection add ` - Remove: `npx --yes folocli@latest collection remove ` - List: `npx --yes folocli@latest collection list --limit 20` ### 5. OPML Import / Export - Export: - `npx --yes folocli@latest opml export --output backup.opml` - Import: - `npx --yes folocli@latest opml import feeds.opml` ## Pagination Pattern `npx --yes folocli@latest timeline` returns: - `entries` - `nextCursor` - `hasNext` Loop until `hasNext` is `false`: 1. `npx --yes folocli@latest timeline --limit 20` 2. Read `nextCursor` 3. `npx --yes folocli@latest timeline --limit 20 --cursor ` 4. Repeat ## Command Reference - `npx --yes folocli@latest login [--timeout ] [--token ]` - `npx --yes folocli@latest logout` - `npx --yes folocli@latest whoami` - `npx --yes folocli@latest auth login [--timeout ] [--token ]` - `npx --yes folocli@latest auth logout` - `npx --yes folocli@latest auth whoami` - `npx --yes folocli@latest timeline [--view ] [--limit ] [--unread-only] [--cursor ]` - `npx --yes folocli@latest timeline --feed [--limit ] [--cursor ]` - `npx --yes folocli@latest timeline --list [--limit ] [--cursor ]` - `npx --yes folocli@latest timeline --category [--view ] [--limit ]` - `npx --yes folocli@latest subscription list [--view ] [--category ]` - `npx --yes folocli@latest subscription add --feed [--category ] [--view ] [--private]` - `npx --yes folocli@latest subscription add --list [--category ] [--view ]` - `npx --yes folocli@latest subscription remove [--target feed|list|url]` - `npx --yes folocli@latest subscription update [--target feed|list] [--category ] [--title ] [--view <type>] [--private|--public]` - `npx --yes folocli@latest entry get <entryId>` - `npx --yes folocli@latest entry read <entryId>` - `npx --yes folocli@latest entry mark-read <entryId>` - `npx --yes folocli@latest entry mark-unread <entryId>` - `npx --yes folocli@latest entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]` - `npx --yes folocli@latest feed get <feedId|feedUrl>` - `npx --yes folocli@latest feed refresh <feedId>` - `npx --yes folocli@latest feed analytics <feedId>` - `npx --yes folocli@latest list ls` - `npx --yes folocli@latest list get <listId>` - `npx --yes folocli@latest list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]` - `npx --yes folocli@latest list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]` - `npx --yes folocli@latest list delete <listId>` - `npx --yes folocli@latest list add-feed <listId> --feed <feedId>` - `npx --yes folocli@latest list remove-feed <listId> --feed <feedId>` - `npx --yes folocli@latest search discover <keyword> [--type feeds|lists]` - `npx --yes folocli@latest search rsshub <keyword> [--lang <lang>]` - `npx --yes folocli@latest search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]` - `npx --yes folocli@latest collection list [--limit <n>] [--cursor <datetime>]` - `npx --yes folocli@latest collection add <entryId> [--view <type>]` - `npx --yes folocli@latest collection remove <entryId>` - `npx --yes folocli@latest opml export [--output <file>]` - `npx --yes folocli@latest opml import <file> [--items <url1,url2,...>]` - `npx --yes folocli@latest unread count` - `npx --yes folocli@latest unread list [--view <type>]` ## Error Recovery - `UNAUTHORIZED` - Re-login: `npx --yes folocli@latest login` - or `npx --yes folocli@latest login --token <token>` - Or set `FOLO_TOKEN` - `HTTP_4xx` / `HTTP_5xx` - Retry with `--verbose` for request details - Verify `--api-url` if using non-default endpoint - `INVALID_ARGUMENT` - Run `npx --yes folocli@latest <command> --help` to inspect accepted options ================================================ FILE: apps/cli/src/args.test.ts ================================================ import { describe, expect, it } from "vitest" import { parseFormat, parseISODate, parseNonNegativeInt, parsePositiveInt, parseView } from "./args" describe("args parsers", () => { it("parses named view values", () => { expect(parseView("articles")).toBe(0) expect(parseView("social")).toBe(1) expect(parseView("pictures")).toBe(2) expect(parseView("videos")).toBe(3) expect(parseView("audio")).toBe(4) expect(parseView("notifications")).toBe(5) }) it("parses numeric view values", () => { expect(parseView("0")).toBe(0) expect(parseView("5")).toBe(5) }) it("throws for invalid view values", () => { expect(() => parseView("foo")).toThrowError(/Invalid view/) expect(() => parseView("6")).toThrowError(/Invalid view/) }) it("parses positive integers", () => { expect(parsePositiveInt("1")).toBe(1) expect(parsePositiveInt("99")).toBe(99) }) it("throws for non-positive integers", () => { expect(() => parsePositiveInt("0")).toThrowError(/positive integer/) expect(() => parsePositiveInt("-1")).toThrowError(/positive integer/) }) it("parses non-negative integers", () => { expect(parseNonNegativeInt("0")).toBe(0) expect(parseNonNegativeInt("3")).toBe(3) }) it("throws for negative integers", () => { expect(() => parseNonNegativeInt("-1")).toThrowError(/non-negative integer/) }) it("parses ISO datetime", () => { expect(parseISODate("2026-02-25T10:30:00Z")).toBe("2026-02-25T10:30:00.000Z") }) it("throws for invalid datetime", () => { expect(() => parseISODate("not-a-date")).toThrowError(/Invalid datetime/) }) it("parses output format", () => { expect(parseFormat("json")).toBe("json") expect(parseFormat("table")).toBe("table") expect(parseFormat("plain")).toBe("plain") }) it("throws for invalid output format", () => { expect(() => parseFormat("yaml")).toThrowError(/Invalid format/) }) }) ================================================ FILE: apps/cli/src/args.ts ================================================ import type { OutputFormat } from "./output" const viewMap: Readonly<Record<string, number>> = { article: 0, articles: 0, social: 1, socialmedia: 1, picture: 2, pictures: 2, video: 3, videos: 3, audio: 4, notification: 5, notifications: 5, } export const viewHelp = "articles(0) | social(1) | pictures(2) | videos(3) | audio(4) | notifications(5)" export const parseView = (value: string): number => { const normalized = value.trim().toLowerCase() if (/^\d+$/.test(normalized)) { const parsed = Number.parseInt(normalized, 10) if (parsed >= 0 && parsed <= 5) { return parsed } } const mapped = viewMap[normalized] if (mapped !== undefined) { return mapped } throw new Error(`Invalid view "${value}". Use ${viewHelp}.`) } export const parsePositiveInt = (value: string): number => { const parsed = Number.parseInt(value, 10) if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`Expected a positive integer, got "${value}".`) } return parsed } export const parseNonNegativeInt = (value: string): number => { const parsed = Number.parseInt(value, 10) if (!Number.isInteger(parsed) || parsed < 0) { throw new Error(`Expected a non-negative integer, got "${value}".`) } return parsed } export const parseISODate = (value: string): string => { const timestamp = Date.parse(value) if (Number.isNaN(timestamp)) { throw new TypeError(`Invalid datetime value "${value}".`) } return new Date(timestamp).toISOString() } export const parseFormat = (value: string): OutputFormat => { if (value === "json" || value === "table" || value === "plain") { return value } throw new Error(`Invalid format "${value}". Use json, table, or plain.`) } ================================================ FILE: apps/cli/src/browser-login.test.ts ================================================ import { describe, expect, it } from "vitest" import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common" import { resolveCLILoginUrl } from "./browser-login" describe("browser login helpers", () => { it("maps production API URL using env.common", () => { const url = resolveCLILoginUrl(DEFAULT_VALUES.PROD.API_URL, "http://127.0.0.1:12345/callback") const parsed = new URL(url) expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.PROD.WEB_URL).origin) expect(parsed.pathname).toBe("/login") expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback") }) it("maps dev API URL using env.common", () => { const url = resolveCLILoginUrl(DEFAULT_VALUES.DEV.API_URL, "http://127.0.0.1:12345/callback") const parsed = new URL(url) expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.DEV.WEB_URL).origin) expect(parsed.pathname).toBe("/login") expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback") }) it("maps local API URL using env.common", () => { const url = resolveCLILoginUrl(DEFAULT_VALUES.LOCAL.API_URL, "http://127.0.0.1:12345/callback") const parsed = new URL(url) expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin) expect(parsed.pathname).toBe("/login") expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback") }) it("falls back to API origin when no mapping exists", () => { const url = resolveCLILoginUrl("https://api.follow.is", "http://localhost:3456/callback") const parsed = new URL(url) expect(parsed.origin).toBe("https://api.follow.is") expect(parsed.pathname).toBe("/login") expect(parsed.searchParams.get("cli_callback")).toBe("http://localhost:3456/callback") }) it("throws for invalid api url", () => { expect(() => resolveCLILoginUrl("not-a-url", "http://127.0.0.1:3333/callback")).toThrowError( /Invalid API URL/, ) }) }) ================================================ FILE: apps/cli/src/browser-login.ts ================================================ import { spawnSync } from "node:child_process" import { createServer } from "node:http" import type { AddressInfo } from "node:net" import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common" import { CLIError } from "./output" const LOCAL_CALLBACK_HOST = "127.0.0.1" const LOCAL_CALLBACK_PATH = "/callback" const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000 const mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [ { apiOrigin: new URL(DEFAULT_VALUES.PROD.API_URL).origin, webOrigin: new URL(DEFAULT_VALUES.PROD.WEB_URL).origin, }, { apiOrigin: new URL(DEFAULT_VALUES.DEV.API_URL).origin, webOrigin: new URL(DEFAULT_VALUES.DEV.WEB_URL).origin, }, { apiOrigin: new URL(DEFAULT_VALUES.LOCAL.API_URL).origin, webOrigin: new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin, }, ] const successPageHtml = `<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Folo CLI Login

Folo CLI login complete

You can close this window and return to your terminal.

` const failurePageHtml = ` Folo CLI Login

Folo CLI login failed

Missing token in callback URL. Please retry in terminal.

` const getOpenBrowserCommand = (url: string): { command: string; args: string[] } => { if (process.platform === "darwin") { return { command: "open", args: [url] } } if (process.platform === "win32") { return { command: "cmd", args: ["/c", "start", "", url] } } return { command: "xdg-open", args: [url] } } const openBrowser = (url: string) => { const { command, args } = getOpenBrowserCommand(url) const result = spawnSync(command, args, { stdio: "ignore", }) if (result.error || result.status !== 0) { throw new CLIError( "BROWSER_OPEN_FAILED", `Failed to open browser automatically. Open this URL manually: ${url}`, ) } } export const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string => { let api: URL try { api = new URL(apiUrl) } catch { throw new CLIError("INVALID_ARGUMENT", `Invalid API URL: ${apiUrl}`) } const mappedWebOrigin = mappedWebOrigins.find((item) => item.apiOrigin === api.origin)?.webOrigin const webUrl = new URL(mappedWebOrigin ?? api.origin) webUrl.pathname = "/login" webUrl.search = "" webUrl.hash = "" webUrl.searchParams.set("cli_callback", callbackUrl) return webUrl.toString() } export interface BrowserLoginOptions { apiUrl: string timeoutMs?: number onStatus?: (message: string) => void } export interface BrowserLoginResult { token: string callbackUrl: string loginUrl: string } export const loginWithBrowser = async ( options: BrowserLoginOptions, ): Promise => { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS const onStatus = options.onStatus ?? (() => {}) if (timeoutMs <= 0) { throw new CLIError("INVALID_ARGUMENT", "Browser login timeout must be greater than 0.") } const result = await new Promise((resolve, reject) => { let settled = false let timer: NodeJS.Timeout | undefined const settle = (handler: () => void) => { if (settled) { return } settled = true if (timer) { clearTimeout(timer) } server.close(() => { handler() }) } const server = createServer((req, res) => { const requestUrl = new URL( req.url ?? "/", `http://${req.headers.host ?? LOCAL_CALLBACK_HOST}`, ) if (requestUrl.pathname !== LOCAL_CALLBACK_PATH) { res.statusCode = 404 res.end("Not Found") return } const token = requestUrl.searchParams.get("token") if (!token) { res.statusCode = 400 res.setHeader("content-type", "text/html; charset=utf-8") res.end(failurePageHtml) return } res.statusCode = 200 res.setHeader("content-type", "text/html; charset=utf-8") res.end(successPageHtml) const callbackAddress = server.address() as AddressInfo | null const callbackUrl = callbackAddress ? `http://${LOCAL_CALLBACK_HOST}:${callbackAddress.port}${LOCAL_CALLBACK_PATH}` : "" const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl) settle(() => { resolve({ token, callbackUrl, loginUrl, }) }) }) server.once("error", (error) => { settle(() => { reject( new CLIError("NETWORK_ERROR", `Failed to start local callback server: ${error.message}`), ) }) }) server.listen(0, LOCAL_CALLBACK_HOST, () => { const address = server.address() as AddressInfo | null if (!address) { settle(() => { reject(new CLIError("NETWORK_ERROR", "Failed to bind local callback server.")) }) return } const callbackUrl = `http://${LOCAL_CALLBACK_HOST}:${address.port}${LOCAL_CALLBACK_PATH}` const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl) onStatus(`Open this URL to sign in: ${loginUrl}`) try { openBrowser(loginUrl) onStatus("Browser opened. Waiting for login confirmation...") } catch (error) { onStatus((error as Error).message) onStatus("Waiting for login confirmation...") } timer = setTimeout(() => { settle(() => { reject( new CLIError( "TIMEOUT", "Timed out waiting for browser login. Please run `folo login` again.", ), ) }) }, timeoutMs) }) }) return result } ================================================ FILE: apps/cli/src/cli.e2e.test.ts ================================================ import type { ExecFileException } from "node:child_process" import { execFile } from "node:child_process" import { mkdtempSync } from "node:fs" import { tmpdir } from "node:os" import { promisify } from "node:util" import { resolve } from "pathe" import { describe, expect, it } from "vitest" const execFileAsync = promisify(execFile) const cliPath = resolve(process.cwd(), "dist/index.js") const testToken = process.env.FOLO_TEST_TOKEN const isolatedHome = mkdtempSync(resolve(tmpdir(), "folocli-test-")) type CLIExecution = { code: number stdout: string stderr: string } const runCLI = async (args: string[]): Promise => { try { const { stdout, stderr } = await execFileAsync("node", [cliPath, ...args], { env: { ...process.env, HOME: isolatedHome, USERPROFILE: isolatedHome, FOLO_TOKEN: "", }, }) return { code: 0, stdout, stderr, } } catch (error) { const execError = error as ExecFileException & { stdout?: string stderr?: string } return { code: typeof execError.code === "number" ? execError.code : 1, stdout: execError.stdout ?? "", stderr: execError.stderr ?? "", } } } describe("cli e2e", () => { it("returns structured unauthorized error without token", async () => { const result = await runCLI(["timeline", "--limit", "1"]) expect(result.code).not.toBe(0) const payload = JSON.parse(result.stderr) as { ok: boolean data: null error: { code: string; message: string } } expect(payload.ok).toBe(false) expect(payload.data).toBeNull() expect(payload.error.code).toBe("UNAUTHORIZED") }) it.runIf(Boolean(testToken))("can fetch session with test token", async () => { const result = await runCLI(["--token", testToken!, "whoami"]) expect(result.code).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean data: { user: { id: string } session: { id: string } } error: null } expect(payload.ok).toBe(true) expect(payload.error).toBeNull() expect(typeof payload.data.user.id).toBe("string") expect(typeof payload.data.session.id).toBe("string") }) it.runIf(Boolean(testToken))("can fetch timeline with test token", async () => { const result = await runCLI(["--token", testToken!, "timeline", "--limit", "1"]) expect(result.code).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean data: { entries: unknown[] nextCursor: string | null hasNext: boolean } error: null } expect(payload.ok).toBe(true) expect(Array.isArray(payload.data.entries)).toBe(true) expect(typeof payload.data.hasNext).toBe("boolean") }) }) ================================================ FILE: apps/cli/src/client.ts ================================================ import { FollowClient } from "@follow-app/client-sdk" import type { Command } from "commander" import type { FoloCLIConfig } from "./config" import { readConfig } from "./config" import type { OutputFormat } from "./output" import { CLIError } from "./output" export const defaultApiURL = "https://api.folo.is" const readString = (value: unknown): string | undefined => { return typeof value === "string" && value.length > 0 ? value : undefined } const normalizeToken = (token: string | undefined) => { if (!token || !token.includes("%")) { return token } try { return decodeURIComponent(token) } catch { return token } } export interface GlobalOptions { format: OutputFormat apiUrl?: string token?: string verbose: boolean } export interface ResolvedGlobalOptions extends GlobalOptions { apiUrl: string } export interface CommandContext { client: FollowClient options: ResolvedGlobalOptions config: FoloCLIConfig token?: string } export const getGlobalOptions = (command: Command): GlobalOptions => { const options = command.optsWithGlobals() as Record return { format: options.format === "table" || options.format === "plain" || options.format === "json" ? options.format : "json", apiUrl: readString(options.apiUrl), token: readString(options.token), verbose: Boolean(options.verbose), } } const setupVerboseLogging = (client: FollowClient) => { client.addRequestInterceptor((ctx) => { const method = ctx.options.method || "GET" console.error(`[request] ${method} ${ctx.url}`) return ctx }) client.addResponseInterceptor((ctx) => { const method = ctx.options.method || "GET" console.error(`[response] ${method} ${ctx.url} -> ${ctx.response.status}`) return ctx.response }) } export const createCommandContext = async ( command: Command, requireAuth = true, ): Promise => { const globalOptions = getGlobalOptions(command) const config = await readConfig() const token = normalizeToken(globalOptions.token ?? process.env.FOLO_TOKEN ?? config.token) const apiUrl = globalOptions.apiUrl ?? config.apiUrl ?? defaultApiURL if (requireAuth && !token) { throw new CLIError( "UNAUTHORIZED", "Missing token. Run `folo login` (browser sign-in) or set FOLO_TOKEN.", ) } const client = new FollowClient({ baseURL: apiUrl, }) if (token) { client.setAuthToken(token) client.setHeaders({ Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, }) } if (globalOptions.verbose) { setupVerboseLogging(client) } return { client, token, config, options: { ...globalOptions, apiUrl, }, } } ================================================ FILE: apps/cli/src/command.ts ================================================ import type { Command } from "commander" import type { CommandContext } from "./client" import { createCommandContext, getGlobalOptions } from "./client" import { normalizeError, printFailure, printSuccess } from "./output" interface RunCommandOptions { requireAuth?: boolean } export const runCommand = async ( command: Command, handler: (context: CommandContext) => Promise, options: RunCommandOptions = {}, ) => { const fallbackOptions = getGlobalOptions(command) const requireAuth = options.requireAuth ?? true let context: CommandContext | null = null try { context = await createCommandContext(command, requireAuth) const result = await handler(context) printSuccess(context.options.format, result) } catch (error) { const format = context?.options.format ?? fallbackOptions.format const verbose = context?.options.verbose ?? fallbackOptions.verbose printFailure(format, normalizeError(error, verbose)) process.exitCode = 1 } } ================================================ FILE: apps/cli/src/commands/auth.ts ================================================ import type { Command } from "commander" import { parsePositiveInt } from "../args" import { loginWithBrowser } from "../browser-login" import { getGlobalOptions } from "../client" import { runCommand } from "../command" import { clearToken, getConfigPath, updateConfig } from "../config" import { CLIError } from "../output" interface AuthLoginOptions { token?: string timeout?: number } const runLoginAction = async function (this: Command, options: AuthLoginOptions) { await runCommand( this, async ({ client, options: globalOptions }) => { let token = options.token ?? getGlobalOptions(this).token if (!token) { const timeoutMs = (options.timeout ?? 180) * 1000 const browserLogin = await loginWithBrowser({ apiUrl: globalOptions.apiUrl, timeoutMs, onStatus: (message) => { console.error(`[auth] ${message}`) }, }) token = browserLogin.token } client.setAuthToken(token) const session = await client.api.auth.getSession() if (!session.user || !session.session) { throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") } await updateConfig({ token, apiUrl: globalOptions.apiUrl, }) return { message: "Login successful.", configPath: getConfigPath(), user: session.user, } }, { requireAuth: false }, ) } const runLogoutAction = async function (this: Command) { await runCommand( this, async () => { await clearToken() return { message: "Logged out.", configPath: getConfigPath(), } }, { requireAuth: false }, ) } const runWhoamiAction = async function (this: Command) { await runCommand(this, async ({ client }) => { const session = await client.api.auth.getSession() if (!session.user || !session.session) { throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") } return { user: session.user, session: session.session, role: session.role, roleEndAt: session.roleEndAt ?? null, feedSubscriptionLimit: session.feedSubscriptionLimit, rsshubSubscriptionLimit: session.rsshubSubscriptionLimit, } }) } const registerLoginCommand = (program: Command, name: string, description: string) => { program .command(name) .description(description) .option("--token ", "Session token from Folo") .option( "--timeout ", "Browser login timeout in seconds (default: 180)", parsePositiveInt, ) .action(runLoginAction) } const registerLogoutCommand = (program: Command, name: string, description: string) => { program.command(name).description(description).action(runLogoutAction) } const registerWhoamiCommand = (program: Command, name: string, description: string) => { program.command(name).description(description).action(runWhoamiAction) } export const registerAuthCommand = (program: Command) => { const authCommand = program.command("auth").description("Authentication commands") registerLoginCommand( authCommand, "login", "Sign in via browser (or save a provided token) and verify authentication", ) registerLogoutCommand(authCommand, "logout", "Clear stored token") registerWhoamiCommand(authCommand, "whoami", "Show current session user") registerLoginCommand(program, "login", "Sign in and store a CLI session") registerLogoutCommand(program, "logout", "Clear the stored CLI session") registerWhoamiCommand(program, "whoami", "Show the current CLI session user") } ================================================ FILE: apps/cli/src/commands/collection.ts ================================================ import type { EntryListRequest } from "@follow-app/client-sdk" import type { Command } from "commander" import { parseISODate, parsePositiveInt, parseView, viewHelp } from "../args" import { runCommand } from "../command" interface CollectionListOptions { limit: number cursor?: string } interface CollectionAddOptions { view?: number } export const registerCollectionCommand = (program: Command) => { const collectionCommand = program.command("collection").description("Manage collections") collectionCommand .command("list") .description("List collected entries") .option("--limit ", "Number of entries to fetch", parsePositiveInt, 20) .option("--cursor ", "Pagination cursor", parseISODate) .action(async function (this: Command, options: CollectionListOptions) { await runCommand(this, async ({ client }) => { const request: EntryListRequest = { isCollection: true, limit: options.limit, publishedAfter: options.cursor, } const response = await client.api.entries.list(request) const entries = response.data const nextCursor = entries.at(-1)?.entries.publishedAt ?? null return { entries, nextCursor, hasNext: Boolean(nextCursor) && entries.length >= options.limit, } }) }) collectionCommand .command("add") .description("Add entry to collection") .argument("", "Entry ID") .option("--view ", `View type: ${viewHelp}`, parseView) .action(async function (this: Command, entryId: string, options: CollectionAddOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.collections.post({ entryId, view: options.view, }) return response.data }) }) collectionCommand .command("remove") .description("Remove entry from collection") .argument("", "Entry ID") .action(async function (this: Command, entryId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.collections.delete({ entryId, }) return response.data }) }) } ================================================ FILE: apps/cli/src/commands/entry.ts ================================================ import type { MarkAllAsReadRequest } from "@follow-app/client-sdk" import type { Command } from "commander" import { parseView, viewHelp } from "../args" import { runCommand } from "../command" import { CLIError } from "../output" interface MarkAllReadOptions { feed?: string list?: string view?: number } export const registerEntryCommand = (program: Command) => { const entryCommand = program.command("entry").description("Read and update entries") entryCommand .command("get") .description("Get entry detail") .argument("", "Entry ID") .action(async function (this: Command, entryId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.entries.get({ id: entryId }) return response.data }) }) entryCommand .command("read") .description("Get readability content") .argument("", "Entry ID") .action(async function (this: Command, entryId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.entries.readability({ id: entryId }) return response.data }) }) entryCommand .command("mark-read") .description("Mark entry as read") .argument("", "Entry ID") .action(async function (this: Command, entryId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.reads.markAsRead({ entryIds: [entryId] }) return response.data }) }) entryCommand .command("mark-unread") .description("Mark entry as unread") .argument("", "Entry ID") .action(async function (this: Command, entryId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.reads.markAsUnread({ entryId }) return response.data }) }) entryCommand .command("mark-all-read") .description("Mark entries as read in current scope") .option("--feed ", "Mark all entries in a feed as read") .option("--list ", "Mark all entries in a list as read") .option("--view ", `View type: ${viewHelp}`, parseView) .action(async function (this: Command, options: MarkAllReadOptions) { await runCommand(this, async ({ client }) => { if (options.feed && options.list) { throw new CLIError("INVALID_ARGUMENT", "Use only one of --feed or --list.") } const request: MarkAllAsReadRequest = { feedId: options.feed, listId: options.list, view: options.view, } const response = await client.api.reads.markAllAsRead(request) return response.data }) }) } ================================================ FILE: apps/cli/src/commands/feed.ts ================================================ import type { Command } from "commander" import { runCommand } from "../command" const isURL = (value: string) => value.startsWith("http://") || value.startsWith("https://") export const registerFeedCommand = (program: Command) => { const feedCommand = program.command("feed").description("Manage feed data") feedCommand .command("get") .description("Get feed detail by feed ID or URL") .argument("", "Feed ID or URL") .action(async function (this: Command, feedIdOrUrl: string) { await runCommand(this, async ({ client }) => { const response = await client.api.feeds.get( isURL(feedIdOrUrl) ? { url: feedIdOrUrl } : { id: feedIdOrUrl }, ) return response.data }) }) feedCommand .command("refresh") .description("Refresh feed") .argument("", "Feed ID") .action(async function (this: Command, feedId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.feeds.refresh({ id: feedId }) return response.data }) }) feedCommand .command("analytics") .description("Get feed analytics") .argument("", "Feed ID") .action(async function (this: Command, feedId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.feeds.analytics({ id: [feedId] }) return response.data }) }) } ================================================ FILE: apps/cli/src/commands/list.ts ================================================ import type { Command } from "commander" import { parseNonNegativeInt, parseView, viewHelp } from "../args" import { runCommand } from "../command" import { CLIError } from "../output" interface ListCreateOptions { title: string description?: string view?: number fee?: number image?: string } interface ListUpdateOptions { title?: string description?: string view?: number fee?: number image?: string } interface ListFeedOptions { feed: string } export const registerListCommand = (program: Command) => { const listCommand = program.command("list").description("Manage lists") listCommand .command("ls") .description("List my lists") .action(async function (this: Command) { await runCommand(this, async ({ client }) => { const response = await client.api.lists.list({}) return response.data }) }) listCommand .command("get") .description("Get list detail") .argument("", "List ID") .action(async function (this: Command, listId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.lists.get({ listId }) return response.data }) }) listCommand .command("create") .description("Create a list") .requiredOption("--title ", "List title") .option("--description <description>", "List description") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--fee <amount>", "List fee", parseNonNegativeInt, 0) .option("--image <url>", "List image URL") .action(async function (this: Command, options: ListCreateOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.lists.create({ title: options.title, description: options.description ?? null, view: options.view ?? 0, fee: options.fee ?? 0, image: options.image ?? null, }) return response.data }) }) listCommand .command("update") .description("Update a list") .argument("<listId>", "List ID") .option("--title <title>", "List title") .option("--description <description>", "List description") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--fee <amount>", "List fee", parseNonNegativeInt) .option("--image <url>", "List image URL") .action(async function (this: Command, listId: string, options: ListUpdateOptions) { await runCommand(this, async ({ client }) => { if ( options.title === undefined && options.description === undefined && options.view === undefined && options.fee === undefined && options.image === undefined ) { throw new CLIError( "INVALID_ARGUMENT", "No update fields provided. Use at least one of --title, --description, --view, --fee, --image.", ) } const response = await client.api.lists.update({ listId, title: options.title, description: options.description, view: options.view, fee: options.fee, image: options.image, }) return response.data }) }) listCommand .command("delete") .description("Delete a list") .argument("<listId>", "List ID") .action(async function (this: Command, listId: string) { await runCommand(this, async ({ client }) => { const response = await client.api.lists.delete({ listId }) return response.data }) }) listCommand .command("add-feed") .description("Add feed to a list") .argument("<listId>", "List ID") .requiredOption("--feed <feedId>", "Feed ID") .action(async function (this: Command, listId: string, options: ListFeedOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.lists.addFeeds({ listId, feedId: options.feed, }) return response.data }) }) listCommand .command("remove-feed") .description("Remove feed from a list") .argument("<listId>", "List ID") .requiredOption("--feed <feedId>", "Feed ID") .action(async function (this: Command, listId: string, options: ListFeedOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.lists.removeFeed({ listId, feedId: options.feed, }) return response.data }) }) } ================================================ FILE: apps/cli/src/commands/opml.ts ================================================ import { mkdir, readFile, writeFile } from "node:fs/promises" import type { Command } from "commander" import { basename, dirname } from "pathe" import { runCommand } from "../command" interface OpmlExportOptions { output?: string } interface OpmlImportOptions { items?: string } const parseItems = (value?: string): string[] => { if (!value) { return [] } return value .split(",") .map((item) => item.trim()) .filter((item) => item.length > 0) } export const registerOPMLCommand = (program: Command) => { const opmlCommand = program.command("opml").description("Import and export OPML") opmlCommand .command("export") .description("Export subscriptions as OPML") .option("--output <file>", "Write exported OPML to file") .action(async function (this: Command, options: OpmlExportOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.subscriptions.export({ format: "opml", }) if (!options.output) { return response } await mkdir(dirname(options.output), { recursive: true }) await writeFile(options.output, response.content, "utf8") return { output: options.output, filename: response.filename, contentType: response.contentType, bytes: Buffer.byteLength(response.content), } }) }) opmlCommand .command("import") .description("Import subscriptions from OPML file") .argument("<file>", "Path to OPML or XML file") .option("--items <urls>", "Comma-separated feed URLs to import from parsed file") .action(async function (this: Command, filePath: string, options: OpmlImportOptions) { await runCommand(this, async ({ client }) => { const fileBuffer = await readFile(filePath) const fileName = basename(filePath) const formData = new FormData() formData.append( "file", new Blob([fileBuffer], { type: "application/octet-stream", }), fileName, ) const items = parseItems(options.items) if (items.length > 0) { formData.append("items", JSON.stringify(items)) } const response = await client.api.subscriptions.import(formData) return response.data }) }) } ================================================ FILE: apps/cli/src/commands/search.ts ================================================ import type { Command } from "commander" import { parsePositiveInt, parseView, viewHelp } from "../args" import { runCommand } from "../command" type DiscoverTarget = "feeds" | "lists" type TrendingRange = "1d" | "3d" | "7d" | "30d" type TrendingLanguage = "eng" | "cmn" const parseDiscoverTarget = (value: string): DiscoverTarget => { if (value === "feeds" || value === "lists") { return value } throw new Error(`Invalid discover type "${value}". Use feeds or lists.`) } const parseTrendingRange = (value: string): TrendingRange => { if (value === "1d" || value === "3d" || value === "7d" || value === "30d") { return value } throw new Error(`Invalid range "${value}". Use one of 1d, 3d, 7d, 30d.`) } const parseTrendingLanguage = (value: string): TrendingLanguage => { if (value === "eng" || value === "cmn") { return value } throw new Error(`Invalid language "${value}". Use eng or cmn.`) } interface SearchDiscoverOptions { type?: DiscoverTarget } interface SearchRsshubOptions { lang?: string } interface SearchTrendingOptions { category?: string range?: TrendingRange view?: number limit?: number language?: TrendingLanguage } export const registerSearchCommand = (program: Command) => { const searchCommand = program.command("search").description("Discover feeds and lists") searchCommand .command("discover") .description("Discover feeds or lists") .argument("<keyword>", "Search keyword") .option("--type <type>", "Discover target: feeds | lists", parseDiscoverTarget) .action(async function (this: Command, keyword: string, options: SearchDiscoverOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.discover.discover({ keyword, target: options.type, }) return response.data }) }) searchCommand .command("rsshub") .description("Search RSSHub routes by category keyword") .argument("<keyword>", "Category keyword") .option("--lang <lang>", "Language tag") .action(async function (this: Command, keyword: string, options: SearchRsshubOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.discover.rsshub({ categories: keyword, lang: options.lang, }) return response.data }) }) searchCommand .command("trending") .description("Get trending feeds") .option("--category <category>", "Filter by category keyword in title/description") .option("--range <range>", "Trending range: 1d | 3d | 7d | 30d", parseTrendingRange, "7d") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--limit <n>", "Result limit", parsePositiveInt, 20) .option("--language <lang>", "Language: eng | cmn", parseTrendingLanguage) .action(async function (this: Command, options: SearchTrendingOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.trending.getFeeds({ range: options.range, view: options.view, limit: options.limit, language: options.language, }) const keyword = options.category?.trim().toLowerCase() const feeds = keyword ? response.data.filter((item) => { const title = item.feed.title?.toLowerCase() || "" const description = item.feed.description?.toLowerCase() || "" return title.includes(keyword) || description.includes(keyword) }) : response.data return { feeds, } }) }) } ================================================ FILE: apps/cli/src/commands/subscription.ts ================================================ import type { SubscriptionCreateRequest, SubscriptionDeleteRequest, SubscriptionUpdateRequest, } from "@follow-app/client-sdk" import type { Command } from "commander" import { parseView, viewHelp } from "../args" import { runCommand } from "../command" import { CLIError } from "../output" type SubscriptionTarget = "feed" | "list" | "url" type UpdateTarget = "feed" | "list" const parseSubscriptionTarget = (value: string): SubscriptionTarget => { if (value === "feed" || value === "list" || value === "url") { return value } throw new Error(`Invalid target "${value}". Use feed, list, or url.`) } const parseUpdateTarget = (value: string): UpdateTarget => { if (value === "feed" || value === "list") { return value } throw new Error(`Invalid target "${value}". Use feed or list.`) } interface SubscriptionListOptions { view?: number category?: string } interface SubscriptionAddOptions { feed?: string list?: string category?: string view?: number private?: boolean title?: string } interface SubscriptionRemoveOptions { target: SubscriptionTarget } interface SubscriptionUpdateOptions { category?: string title?: string view?: number private?: boolean public?: boolean target: UpdateTarget } export const registerSubscriptionCommand = (program: Command) => { const subscriptionCommand = program.command("subscription").description("Manage subscriptions") subscriptionCommand .command("list") .description("List subscriptions") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--category <name>", "Filter by category") .action(async function (this: Command, options: SubscriptionListOptions) { await runCommand(this, async ({ client }) => { const response = await client.api.subscriptions.get( options.view !== undefined ? { view: options.view } : {}, ) const subscriptions = options.category ? response.data.filter((item) => item.category === options.category) : response.data return { subscriptions, } }) }) subscriptionCommand .command("add") .description("Add a feed or list subscription") .option("--feed <url>", "Feed URL to subscribe") .option("--list <listId>", "List ID to subscribe") .option("--category <name>", "Subscription category") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--private", "Mark subscription as private", false) .option("--title <title>", "Custom subscription title") .action(async function (this: Command, options: SubscriptionAddOptions) { await runCommand(this, async ({ client }) => { const selected = [options.feed, options.list].filter(Boolean) if (selected.length !== 1) { throw new CLIError("INVALID_ARGUMENT", "Use either --feed or --list when adding.") } const request: SubscriptionCreateRequest = { view: options.view ?? 0, category: options.category ?? null, isPrivate: options.private || false, title: options.title ?? null, } if (options.feed) { request.url = options.feed request.type = "feed" } if (options.list) { request.listId = options.list request.type = "list" } const response = await client.api.subscriptions.create(request) return { feed: response.feed, list: response.list, unread: response.unread, } }) }) subscriptionCommand .command("remove") .description("Remove a subscription target") .argument("<id>", "Feed ID, list ID, or feed URL") .option( "--target <target>", "Subscription target type: feed | list | url", parseSubscriptionTarget, "feed", ) .action(async function (this: Command, id: string, options: SubscriptionRemoveOptions) { await runCommand(this, async ({ client }) => { const request: SubscriptionDeleteRequest = {} if (options.target === "feed") { request.feedId = id } else if (options.target === "list") { request.listId = id } else { request.url = id } const response = await client.api.subscriptions.delete(request) return response.data }) }) subscriptionCommand .command("update") .description("Update a subscription target") .argument("<id>", "Feed ID or list ID") .option("--category <name>", "Set category") .option("--title <title>", "Set custom title") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--private", "Set subscription private", false) .option("--public", "Set subscription public", false) .option("--target <target>", "Target type: feed | list", parseUpdateTarget, "feed") .action(async function (this: Command, id: string, options: SubscriptionUpdateOptions) { await runCommand(this, async ({ client }) => { if (options.private && options.public) { throw new CLIError( "INVALID_ARGUMENT", "Use only one of --private or --public when updating.", ) } const request: SubscriptionUpdateRequest = { category: options.category ?? undefined, title: options.title ?? undefined, view: options.view, } if (options.private) { request.isPrivate = true } else if (options.public) { request.isPrivate = false } if (options.target === "feed") { request.feedId = id } else { request.listId = id } if ( request.category === undefined && request.title === undefined && request.view === undefined && request.isPrivate === undefined ) { throw new CLIError( "INVALID_ARGUMENT", "No update fields provided. Use at least one of --category, --title, --view, --private, --public.", ) } const response = await client.api.subscriptions.update(request) return response.data }) }) } ================================================ FILE: apps/cli/src/commands/timeline.ts ================================================ import type { EntryListRequest } from "@follow-app/client-sdk" import type { Command } from "commander" import { parseISODate, parsePositiveInt, parseView, viewHelp } from "../args" import { runCommand } from "../command" import { CLIError } from "../output" type TimelineQuery = EntryListRequest & { listId?: string } interface TimelineOptions { view?: number limit: number unreadOnly?: boolean cursor?: string feed?: string list?: string category?: string } export const registerTimelineCommand = (program: Command) => { program .command("timeline") .description("List timeline entries") .option("--view <type>", `View type: ${viewHelp}`, parseView) .option("--limit <n>", "Number of entries to fetch", parsePositiveInt, 20) .option("--unread-only", "Only unread entries", false) .option("--cursor <datetime>", "Pagination cursor (publishedAfter)", parseISODate) .option("--feed <feedId>", "Filter timeline by feed") .option("--list <listId>", "Filter timeline by list") .option("--category <name>", "Filter timeline by subscription category") .action(async function (this: Command, options: TimelineOptions) { await runCommand(this, async ({ client }) => { const scopedFilters = [options.feed, options.list, options.category].filter(Boolean) if (scopedFilters.length > 1) { throw new CLIError( "INVALID_ARGUMENT", "Use only one of --feed, --list, or --category at the same time.", ) } const query: TimelineQuery = { limit: options.limit, read: options.unreadOnly ? false : undefined, view: options.view, publishedAfter: options.cursor, } if (options.feed) { query.feedId = options.feed } if (options.list) { query.listId = options.list } if (options.category) { const subscriptions = await client.api.subscriptions.get( options.view !== undefined ? { view: options.view } : {}, ) const feedIdList = subscriptions.data .filter((item) => item.category === options.category) .map((item) => item.feedId) .filter((item): item is string => item.length > 0) if (feedIdList.length === 0) { return { entries: [], nextCursor: null, hasNext: false, } } query.feedIdList = feedIdList } const response = await client.api.entries.list(query) const entries = response.data const nextCursor = entries.at(-1)?.entries.publishedAt ?? null return { entries, nextCursor, hasNext: Boolean(nextCursor) && entries.length >= options.limit, } }) }) } ================================================ FILE: apps/cli/src/commands/unread.ts ================================================ import type { InboxSubscriptionResponse, ListSubscriptionResponse, SubscriptionWithFeed, } from "@follow-app/client-sdk" import type { Command } from "commander" import { parseView, viewHelp } from "../args" import { runCommand } from "../command" interface UnreadListOptions { view?: number } const isInboxSubscription = ( value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse, ): value is InboxSubscriptionResponse => { return "inboxes" in value } const isListSubscription = ( value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse, ): value is ListSubscriptionResponse => { return "lists" in value } const resolveTitle = ( value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse, ): string | null => { if (value.title) { return value.title } if ("feeds" in value) { return value.feeds.title ?? null } if ("lists" in value) { return value.lists.title ?? null } if ("inboxes" in value) { return value.inboxes.title ?? null } return null } export const registerUnreadCommand = (program: Command) => { const unreadCommand = program.command("unread").description("Unread status commands") unreadCommand .command("count") .description("Get total unread count") .action(async function (this: Command) { await runCommand(this, async ({ client }) => { const response = await client.api.reads.getTotalCount() return response.data }) }) unreadCommand .command("list") .description("List subscriptions with unread entries") .option("--view <type>", `View type: ${viewHelp}`, parseView) .action(async function (this: Command, options: UnreadListOptions) { await runCommand(this, async ({ client }) => { const [unreadResponse, subscriptionsResponse] = await Promise.all([ client.api.reads.get(options.view !== undefined ? { view: options.view } : {}), client.api.subscriptions.get(options.view !== undefined ? { view: options.view } : {}), ]) const unreadMap = unreadResponse.data const items = subscriptionsResponse.data .map((subscription) => { const unreadKey = isInboxSubscription(subscription) ? subscription.inboxId : subscription.feedId const unreadCount = unreadMap[unreadKey] ?? 0 return { sourceType: isInboxSubscription(subscription) ? "inbox" : isListSubscription(subscription) ? "list" : "feed", sourceId: isInboxSubscription(subscription) ? subscription.inboxId : isListSubscription(subscription) ? subscription.listId : subscription.feedId, feedId: subscription.feedId, title: resolveTitle(subscription), category: subscription.category ?? null, view: subscription.view, unreadCount, isPrivate: subscription.isPrivate, } }) .filter((item) => item.unreadCount > 0) .sort((left, right) => right.unreadCount - left.unreadCount) return { total: items.reduce((sum, item) => sum + item.unreadCount, 0), items, } }) }) } ================================================ FILE: apps/cli/src/config.ts ================================================ import { mkdir, readFile, writeFile } from "node:fs/promises" import { homedir } from "node:os" import { join } from "pathe" export interface FoloCLIConfig { token?: string apiUrl?: string } const configDir = join(homedir(), ".folo") const configPath = join(configDir, "config.json") const normalizeConfig = (config: unknown): FoloCLIConfig => { if (!config || typeof config !== "object") { return {} } const source = config as Record<string, unknown> return { token: typeof source.token === "string" ? source.token : undefined, apiUrl: typeof source.apiUrl === "string" ? source.apiUrl : undefined, } } export const getConfigPath = () => configPath export const readConfig = async (): Promise<FoloCLIConfig> => { try { const raw = await readFile(configPath, "utf8") return normalizeConfig(JSON.parse(raw)) } catch (error) { const nodeError = error as NodeJS.ErrnoException if (nodeError.code === "ENOENT") { return {} } throw error } } const ensureConfigDir = async () => { await mkdir(configDir, { recursive: true }) } export const writeConfig = async (config: FoloCLIConfig) => { await ensureConfigDir() await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8") } export const updateConfig = async (patch: Partial<FoloCLIConfig>) => { const current = await readConfig() const next: FoloCLIConfig = { ...current, ...patch, } if (!next.token) { delete next.token } if (!next.apiUrl) { delete next.apiUrl } await writeConfig(next) return next } export const clearToken = async () => { const current = await readConfig() if (!current.token) { return } delete current.token await writeConfig(current) } ================================================ FILE: apps/cli/src/index.ts ================================================ import { Command } from "commander" import packageJSON from "../package.json" import { parseFormat } from "./args" import { defaultApiURL } from "./client" import { registerAuthCommand } from "./commands/auth" import { registerCollectionCommand } from "./commands/collection" import { registerEntryCommand } from "./commands/entry" import { registerFeedCommand } from "./commands/feed" import { registerListCommand } from "./commands/list" import { registerOPMLCommand } from "./commands/opml" import { registerSearchCommand } from "./commands/search" import { registerSubscriptionCommand } from "./commands/subscription" import { registerTimelineCommand } from "./commands/timeline" import { registerUnreadCommand } from "./commands/unread" import type { OutputFormat } from "./output" import { normalizeError, printFailure } from "./output" const program = new Command() program .name("folo") .description("Folo CLI client for structured automation") .version(packageJSON.version) .option("-f, --format <format>", "Output format: json | table | plain", parseFormat, "json") .option("--api-url <url>", `API base URL (default: ${defaultApiURL})`) .option("--token <token>", "Override stored token") .option("--verbose", "Enable verbose request/response logging", false) registerAuthCommand(program) registerTimelineCommand(program) registerSubscriptionCommand(program) registerEntryCommand(program) registerFeedCommand(program) registerListCommand(program) registerSearchCommand(program) registerCollectionCommand(program) registerOPMLCommand(program) registerUnreadCommand(program) const resolveRequestedFormat = (argv: string[]): OutputFormat => { for (let index = 0; index < argv.length; index += 1) { const current = argv[index] if ((current === "--format" || current === "-f") && argv[index + 1]) { try { return parseFormat(argv[index + 1]!) } catch { return "json" } } } return "json" } try { await program.parseAsync(process.argv) } catch (error) { const format = resolveRequestedFormat(process.argv) printFailure(format, normalizeError(error)) process.exitCode = 1 } ================================================ FILE: apps/cli/src/output.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { CLIError, normalizeError, printFailure, printSuccess } from "./output" describe("output helpers", () => { beforeEach(() => { vi.restoreAllMocks() }) afterEach(() => { vi.restoreAllMocks() }) it("prints JSON success envelope", () => { const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}) printSuccess("json", { value: 1 }) expect(infoSpy).toHaveBeenCalledTimes(1) const payload = JSON.parse(infoSpy.mock.calls[0]![0] as string) as { ok: boolean data: { value: number } error: null } expect(payload.ok).toBe(true) expect(payload.data).toEqual({ value: 1 }) expect(payload.error).toBeNull() }) it("prints JSON failure envelope", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) printFailure("json", { code: "E_TEST", message: "boom" }) expect(errorSpy).toHaveBeenCalledTimes(1) const payload = JSON.parse(errorSpy.mock.calls[0]![0] as string) as { ok: boolean data: null error: { code: string; message: string } } expect(payload.ok).toBe(false) expect(payload.data).toBeNull() expect(payload.error).toEqual({ code: "E_TEST", message: "boom" }) }) it("prints ascii table in table mode", () => { const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}) printSuccess("table", [{ id: "a", count: 1 }]) expect(infoSpy).toHaveBeenCalledTimes(1) const output = infoSpy.mock.calls[0]![0] as string expect(output).toContain("| id") expect(output).toContain("| a") }) it("normalizes CLIError", () => { const normalized = normalizeError(new CLIError("E_CODE", "message")) expect(normalized).toEqual({ code: "E_CODE", message: "message", }) }) it("normalizes generic Error", () => { const normalized = normalizeError(new Error("generic")) expect(normalized).toEqual({ code: "UNKNOWN_ERROR", message: "generic", }) }) }) ================================================ FILE: apps/cli/src/output.ts ================================================ import { inspect } from "node:util" import { FollowAPIError, FollowAuthError } from "@follow-app/client-sdk" export type OutputFormat = "json" | "table" | "plain" export interface OutputError { code: string message: string } export class CLIError extends Error { readonly code: string constructor(code: string, message: string) { super(message) this.name = "CLIError" this.code = code } } const stringifyJSON = (value: unknown) => { return JSON.stringify( value, (_key, currentValue) => { if (typeof currentValue === "bigint") { return currentValue.toString() } return currentValue }, 2, ) } const isRecord = (value: unknown): value is Record<string, unknown> => { return typeof value === "object" && value !== null && !Array.isArray(value) } const toCellValue = (value: unknown): string | number | boolean | null => { if ( value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { return value as string | number | boolean | null } if (value === undefined) { return "" } return stringifyJSON(value) } const toTableRow = (value: unknown): Record<string, string | number | boolean | null> => { if (!isRecord(value)) { return { value: toCellValue(value), } } const row: Record<string, string | number | boolean | null> = {} for (const [key, currentValue] of Object.entries(value)) { row[key] = toCellValue(currentValue) } return row } const renderAsciiTable = ( rows: Array<Record<string, string | number | boolean | null>>, ): string => { if (rows.length === 0) { return "(empty)" } const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))) const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length)), ) const formatLine = (values: string[]) => { return `| ${values.map((value, index) => value.padEnd(widths[index]!)).join(" | ")} |` } const border = `+-${widths.map((width) => "-".repeat(width)).join("-+-")}-+` const header = formatLine(columns) const body = rows.map((row) => formatLine(columns.map((column) => String(row[column] ?? "")))) return [border, header, border, ...body, border].join("\n") } const renderTable = (data: unknown) => { const rows = Array.isArray(data) ? data.map(toTableRow) : [toTableRow(data)] console.info(renderAsciiTable(rows)) } const renderPlain = (data: unknown) => { if (Array.isArray(data)) { console.info(data.map((item) => inspect(item, { depth: null, colors: false })).join("\n")) return } if (typeof data === "string") { console.info(data) return } if (data === null || data === undefined) { console.info("") return } console.info( inspect(data, { depth: null, colors: false, compact: false, }), ) } export const printSuccess = (format: OutputFormat, data: unknown) => { if (format === "json") { const payload = { ok: true as const, data, error: null, } console.info(stringifyJSON(payload)) return } if (format === "table") { renderTable(data) return } renderPlain(data) } export const printFailure = (format: OutputFormat, error: OutputError) => { if (format === "json") { const payload = { ok: false as const, data: null, error, } console.error(stringifyJSON(payload)) return } console.error(`[${error.code}] ${error.message}`) } const firstLine = (message: string) => { const [head] = message.split("\n") return head?.trim() || message } export const normalizeError = (error: unknown, verbose = false): OutputError => { if (error instanceof CLIError) { return { code: error.code, message: error.message, } } if (error instanceof FollowAuthError) { return { code: "UNAUTHORIZED", message: verbose ? error.message : firstLine(error.message), } } if (error instanceof FollowAPIError) { return { code: error.code ?? `HTTP_${error.status}`, message: verbose ? error.message : firstLine(error.message), } } if (error instanceof Error) { return { code: "UNKNOWN_ERROR", message: error.message, } } return { code: "UNKNOWN_ERROR", message: "An unknown error occurred", } } ================================================ FILE: apps/cli/tsconfig.json ================================================ { "extends": "@follow/configs/tsconfig.extend.json", "compilerOptions": { "target": "ES2022", "module": "ESNext", "lib": ["ES2022"], "moduleResolution": "Bundler", "types": ["node"], "noEmit": true }, "include": ["src/**/*.ts", "tsup.config.ts"] } ================================================ FILE: apps/cli/tsup.config.ts ================================================ import { defineConfig } from "tsup" export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], target: "node18", outDir: "dist", clean: true, sourcemap: false, dts: false, splitting: false, shims: false, banner: { js: "#!/usr/bin/env node", }, }) ================================================ FILE: apps/cli/vitest.config.ts ================================================ import { defineProject } from "vitest/config" export default defineProject({ test: { environment: "node", }, }) ================================================ FILE: apps/desktop/.env.example ================================================ VITE_WEB_URL=http://localhost:5173 VITE_API_URL=http://localhost:3000 VITE_IMGPROXY_URL=http://localhost:2873 VITE_SENTRY_DSN= VITE_BUILD_TYPE=production VITE_INBOXES_EMAIL=@follow.re VITE_EDITOR=cursor VITE_PUBLIC_POSTHOG_KEY= VITE_PUBLIC_POSTHOG_HOST= ================================================ FILE: apps/desktop/AGENTS.md ================================================ # AGENTS.md This file provides specific guidance for developing the Electron desktop application. ## Architecture - **Main Process** (`layer/main/`) - Electron main process handling system integration - **Renderer Process** (`layer/renderer/`) - Vite + React renderer (primary web app) The renderer is the **primary web application** - a Vite + React SPA that can run both in Electron and as a standalone web app. ## UI Style - **UI Design Style**: Follow Vercel and Linear SaaS UI aesthetics - clean, modern, minimal design with subtle shadows, rounded corners, and excellent typography - **Tailwind CSS** for styling across all platforms - Platform-specific Tailwind configs in each app ## Development Commands ```bash # Recommended: Browser development (faster) pnpm run dev:web # Full Electron development pnpm run dev:electron # Build web version pnpm run build:web ``` ## UIKit Colors for Desktop Components For 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: **System Colors**: `text-red`, `bg-red`, `border-red` (same for `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `gray`) **Fill Colors**: - 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` - Border: `border-fill`, `border-fill-secondary`, etc. **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` **Material Colors**: `bg-material-ultra-thick`, `bg-material-thick`, `bg-material-medium`, `bg-material-thin`, `bg-material-ultra-thin`, `bg-material-opaque` **Control Colors**: `bg-control-enabled`, `bg-control-disabled` **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` These 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. ## Icons For 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. **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. Examples: - Preferred: `i-mgc-copy-cute-re`, `i-mgc-external-link-cute-re` - Fallback only: `i-mingcute-copy-line` (only if no mgc equivalent exists) ## Using Framer Motion - **LazyMotion Integration**: Project uses Framer Motion with LazyMotion for optimized bundle size - **Usage Rule**: Always use `m.` instead of `motion.` when creating animated components - **Import**: `import { m } from 'motion/react'` - **Examples**: `m.div`, `m.button`, `m.span` (not `motion.div`, `motion.button`, etc.) - **Benefits**: Reduces bundle size while maintaining all Framer Motion functionality - **Prefer Spring Presets**: Use predefined spring animations from `@follow/components/constants/spring.js` - **Available Presets Constants**: `Spring.presets.smooth`, `Spring.presets.snappy`, `Spring.presets.bouncy` (extracted from Apple's spring parameters) - **Usage Example**: `transition={Spring.presets.smooth}` or `transition={Spring.snappy(0.3, 0.1)}` - **Customization**: All presets accept optional `duration` and `extraBounce` parameters ## Reusable UI Components When building UI components, follow this hierarchy: 1. **Check Existing Components First**: Look in `apps/desktop/layer/renderer/src/modules/renderer/components` for app-specific components 2. **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` ### Guidelines for Reusable Components (`packages/internal/components`) - **Purpose**: Components should be generic and reusable across different apps/contexts - **No Business Logic**: Avoid coupling with specific app business logic, APIs, or state management - **Follow All Style Rules**: Must adhere to UIKit colors, MingCute icons (`i-mgc-` prefix), and Framer Motion (`m.` prefix) guidelines - **Export Pattern**: Export components from appropriate index files for clean imports - **TypeScript**: Use proper TypeScript interfaces for props and maintain type safety **Example Structure**: ``` packages/internal/components/ ├── ui/ # Basic UI primitives (Button, Input, Modal) ├── layout/ # Layout components (Grid, Stack, Container) ├── feedback/ # User feedback (Toast, Loading, Alert) └── index.ts # Main exports ``` **Import Examples**: ```tsx // Correct - from shared components import { Button, Modal } from "@follow/components" // App-specific components stay in name/components import { FeedList } from "~/modules/name/components" ``` ## Glassmorphic Depth Design System Follow 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. ### Design Principles - **Multi-layer Depth**: Create visual depth through stacked transparent layers - **Subtle Color Accents**: Use brand colors at very low opacity (5-20%) for borders, glows, and backgrounds - **Refined Blur**: Heavy backdrop blur (backdrop-blur-2xl) for frosted glass effect - **Minimal Shadows**: Combine multiple soft shadows with accent colors for depth perception - **Smooth Animations**: Use Spring presets for all transitions ### Color Usage - **Primary Accent**: Use CSS variable `--fo-a` (HSL: `331.7 84% 67%`) at 5-20% opacity for borders, glows, and highlights - **Border**: `hsl(var(--fo-a) / 0.2)` for main borders - **Inner Glow**: `hsl(var(--fo-a) / 0.05)` for subtle radial/linear gradients inside containers - **Shadows**: Layered shadows with accent tint: - `0 8px 32px hsl(var(--fo-a) / 0.08)` - large soft glow - `0 4px 16px hsl(var(--fo-a) / 0.06)` - medium shadow - `0 2px 8px rgba(0, 0, 0, 0.1)` - close depth ### Component Structure ```tsx <div className="rounded-2xl backdrop-blur-2xl" style={{ backgroundImage: "linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))", borderWidth: "1px", borderStyle: "solid", borderColor: "hsl(var(--fo-a) / 0.2)", boxShadow: "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)", }} > {/* Inner glow layer */} <div className="absolute inset-0 rounded-2xl" style={{ background: "linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))", }} /> {/* Content */} <div className="relative">{/* Your content here */}</div> </div> ``` ### Interactive Elements For hover states on buttons or interactive areas within glass containers: ```tsx <button onMouseEnter={(e) => { e.currentTarget.style.background = "linear-gradient(to right, hsl(var(--fo-a) / 0.08), hsl(var(--fo-a) / 0.05))" }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent" }} > {/* Subtle shine effect */} <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" /> </button> ``` ### Dividers Use gradient dividers within glass containers: ```tsx <div className="mx-4 h-px" style={{ background: "linear-gradient(to right, transparent, hsl(var(--fo-a) / 0.2), transparent)", }} /> ``` ### Animation Guidelines - Entry animations: `initial={{ y: 8, opacity: 0 }}` → `animate={{ y: 0, opacity: 1 }}` - Use `Spring.presets.snappy` for quick interactions - Use `Spring.presets.smooth` for larger movements - Keep scale animations subtle (1.0 ↔ 1.02) ### When to Use Apply this design system to: - Toast notifications - Modal dialogs - Floating panels and popovers - Ambient UI prompts - Contextual menus - Elevated cards with actions ### Accessibility - Ensure sufficient contrast for text over glass backgrounds - Maintain border visibility in both light and dark modes - Preserve keyboard focus indicators - Keep animations respectful of `prefers-reduced-motion` ## Build Outputs - Desktop: `apps/desktop/out/` for packaged applications - Web: `apps/desktop/out/web/` for static web assets ================================================ FILE: apps/desktop/build/appxmanifest-template.xml ================================================ <?xml version="1.0" encoding="utf-8"?> <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"> <Identity Name="{identityName}" ProcessorArchitecture="x64" Publisher="{publisherName}" Version="{packageVersion}" /> <Properties> <DisplayName>{packageDisplayName}</DisplayName> <PublisherDisplayName>{publisherDisplayName}</PublisherDisplayName> <Description>No description entered</Description> <Logo>assets\SampleAppx.50x50.png</Logo> </Properties> <Resources> <Resource Language="en-us" /> <Resource Language="zh-cn" /> <Resource Language="zh-tw" /> <Resource Language="ja" /> </Resources> <Dependencies> <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.14316.0" MaxVersionTested="10.0.14316.0" /> </Dependencies> <Capabilities> <rescap:Capability Name="runFullTrust"/> </Capabilities> <Applications> <Application Id="{packageName}" Executable="{packageExecutable}" EntryPoint="Windows.FullTrustApplication"> <uap:VisualElements BackgroundColor="{packageBackgroundColor}" DisplayName="{packageDisplayName}" Square150x150Logo="assets\SampleAppx.150x150.png" Square44x44Logo="assets\SampleAppx.44x44.png" Description="{packageDescription}"> <uap:DefaultTile Wide310x150Logo="assets\SampleAppx.310x150.png" /> </uap:VisualElements> <Extensions>{protocol} </Extensions> </Application> </Applications> </Package> ================================================ FILE: apps/desktop/build/entitlements.mac.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.cs.allow-jit</key> <true/> </dict> </plist> ================================================ FILE: apps/desktop/build/entitlements.mas.child.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.inherit</key> <true/> </dict> </plist> ================================================ FILE: apps/desktop/build/entitlements.mas.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/> <key>com.apple.security.files.bookmarks.app-scope</key> <true/> <key>com.apple.security.network.client</key> <true/> </dict> </plist> ================================================ FILE: apps/desktop/bump.config.ts ================================================ /* eslint-disable no-template-curly-in-string */ import { defineConfig } from "nbump" export default defineConfig({ leading: [ "git pull --rebase", "tsx scripts/apply-changelog.ts ${NEW_VERSION}", "git add changelog", "tsx plugins/vite/generate-main-hash.ts", "pnpm eslint --fix package.json", "pnpm prettier --ignore-unknown --write package.json", "git add package.json", ], trailing: ["git checkout -b release/desktop/${NEW_VERSION}"], finally: [ "git push origin release/desktop/${NEW_VERSION}", "gh pr create --title 'release(desktop): Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base main --head release/desktop/${NEW_VERSION}", ], push: false, commitMessage: "release(desktop): release v${NEW_VERSION}", tagPrefix: "desktop@", changelog: false, allowedBranches: ["dev"], }) ================================================ FILE: apps/desktop/bump.hotfix.config.js ================================================ /* eslint-disable no-template-curly-in-string */ import { execSync } from "node:child_process" import { defineConfig } from "nbump" const currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim() export default defineConfig({ before: ["git pull --rebase"], after: [ `gh pr create --title 'chore(desktop): Release v\${NEW_VERSION} for hotfix' --body 'v\${NEW_VERSION}' --base main --head ${currentBranch}`, ], commit_message: "release(desktop): hotfix to release v${NEW_VERSION}", tag: false, changelog: false, allowedBranches: ["hotfix/*"], }) ================================================ FILE: apps/desktop/changelog/0.1.2.md ================================================ # What's new in v0.1.2 ## Features - Custom CSS is now supported, so you can add any CSS style and apply it to the Entry content view. - 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. - Support for hiding extra badges around the feed, such as Claim or Boost, which you may not want to see. ## Improvements - Fixed the issue that Entry list cannot be loaded offline. - Optimized the performance experience of some scenarios. - The UI of some components has been fine-tuned to look more natural. ================================================ FILE: apps/desktop/changelog/0.2.0.md ================================================ # What's new in v0.2.0 ## New Features - Feed owners can now reset their feeds. - 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!). - Now you can export the data from the local database. - App: In consideration of your hard drive, now it supports clearing cache and limiting the size of cache. - Added a new feature to allow minimizing to the system tray by enabling a switch in the settings. - Discover Page: Enhance RSSHub recommendations with filters - Quickly update views or categories at once by dragging and dropping. ## Improvements - Optimized the Zen mode experience on macOS. - Improvement web app global shortcuts. - 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. ## Bug Fixes - Fixed the issue where the Volume of VideoPlayer was not clickable. - While using arrow keys to switch between entries, the entry view will not scroll unexpexted. ================================================ FILE: apps/desktop/changelog/0.2.1.md ================================================ # What's new in v0.2.1 ## New Features - Added zoom functionality for viewing pictures. - Set `Show Unread Only` as the default option. - Merged the redirect page with the login page. - Introduced entry conditions for actions. - Added `all` language filter in dicover page. ## Improvements - Enhanced the logic for multi-selection and dragging. ## Bug Fixes - Resolved issue with loading Instagram images. ================================================ FILE: apps/desktop/changelog/0.2.2.md ================================================ # What's new in v0.2.2 ## New Features - And Or conditions for actions - Add achievement badge ## Improvements - electron: Prompt when opening an external link whether to open the app - Improve the smoothness of some animations ================================================ FILE: apps/desktop/changelog/0.2.3.md ================================================ # What's new in v0.2.3 ## New Features - Hold shift to quickly select multiple Feeds - Collect entry from List ## Improvements ## Bug Fixes - Action settings are invalid after loading archive entry - Redundant parameter on the transform form - Can't play normal video in video view ================================================ FILE: apps/desktop/changelog/0.2.4.md ================================================ # What's new in v0.2.4-web ## New Features 🎉 HUGE NEWS! Follow finally goes mobile! Ever 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! ================================================ FILE: apps/desktop/changelog/0.2.5.md ================================================ # What's new in v0.2.5 ## New Features - Customizable columns for masonry view - Manually trigger AI summary or translation ![](https://fastly.jsdelivr.net/gh/RSSNext/assets@main/masonry.mp4) ## Improvements ## Bug Fixes - Fixed some display and operation issues on mobile ================================================ FILE: apps/desktop/changelog/0.2.6.md ================================================ # What's new in v0.2.6 We've made some ux optimizations: - [Mobile]: Now when returning to the list from the entry details, it will not return to the top of the page. - [Social Media]: Optimized the arrangement of multiple images with different aspect ratios. - [Social Media]: Long text auto-collapse. And many other bug fixes and improvements. ================================================ FILE: apps/desktop/changelog/0.2.7.md ================================================ # What's new in v0.2.7 ## New Features 1. New `Move to Category` operation in feed subscription context menu ![](https://github.com/RSSNext/assets/blob/main/0.2.7/move-category.png?raw=true) 2. New `Expand long social media` setting to automatically expand social media entries containing long text. ![](https://github.com/RSSNext/assets/blob/main/0.2.7/expand-long-social-media.png?raw=true) 3. New `Back Top` button and read progress indicator in entry content ![](https://github.com/RSSNext/assets/blob/main/0.2.7/read-indicator.png?raw=true) 4. Independent Action page ## Bug Fixes - Resolved issue where back navigation to the pending view would not behave correctly after changing orientation in some device. - Fixed issue can't filter entries with no picture in the picture view. ================================================ FILE: apps/desktop/changelog/0.2.8.md ================================================ # What's new in v0.2.8 ## New Features - Register or Login with email and password ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.2.9.md ================================================ # What's new in v0.2.9 ## New Features ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.0.md ================================================ # What's new in v0.3.0 ## New Features - **Custom RSSHub Instances**: Use custom RSSHub instances to improve data acquisition efficiency. Contribute your self-deployed RSSHub instances to earn additional $POWER. ![Custom RSSHub](https://github.com/RSSNext/assets/blob/main/custom-rsshub.png?raw=true) - **OPML Export Options**: Optionally specify the RSSHub URL and folder classification mode when exporting OPML files. ![Export OPML](https://github.com/RSSNext/assets/blob/main/export-options.png?raw=true) - **Account Management**: Update your email and manage linked social accounts easily. ![Account Management](https://github.com/RSSNext/assets/blob/main/account-management.png?raw=true) ## Improvements - Update email and manage linked social accounts - Scroll to top when re-navigating to Discover page while already on it ## Bug Fixes Some bugs are fixed. ================================================ FILE: apps/desktop/changelog/0.3.1.md ================================================ # What's new in v0.3.1 ## New Features - **Customize toolbar**: Customize the toolbar to display the items you most frequently use. ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true) ## Improvements - **Podcast Player**: Re-designed the podcast player in mobile to be more user-friendly. ![Podcast Player](https://github.com/RSSNext/assets/blob/8f778dac8bb2e765acab2157497e4a77a60c5a0b/mobile-audio-player.png?raw=true) - **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. ![Social Media View Action](https://github.com/RSSNext/assets/blob/main/social-media-actions.png?raw=true) ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.10.md ================================================ # What's new in v0.3.10 ## New Features ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.11.md ================================================ # What's new in v0.3.11 ## New Features ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.12.md ================================================ # What's new in v0.3.12 ## New Features ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.13.md ================================================ # What's new in v0.3.13 ## New Features - New Action Language setting - Export entry content to a PDF file ## Improvements ## Bug Fixes - Unable to update subscription information in time ================================================ FILE: apps/desktop/changelog/0.3.2.md ================================================ # What's new in v0.3.2 ## New Features - Added support for Two-Factor Authentication (2FA) for login and large transactions. ## Improvements - Enhanced detection for translation needs and improved display of translated text. ## Bug Fixes - Resolved an issue where some read marks were missed during fast scrolling. - Unable to copy inbox email address correctly ================================================ FILE: apps/desktop/changelog/0.3.3.md ================================================ # What's new in v0.3.3 ## New Features ## Improvements - Merge actions for toggling state - Action supports matching custom title ## Bug Fixes - `Failed to set voice` error message appears every time the app starts - Can not replay TTS - Login state lost when restarting the app ================================================ FILE: apps/desktop/changelog/0.3.4.md ================================================ # What's new in v0.3.4 ## New Features - The audio and notification views have been merged into the article view, being included in a new timeline selector ![Timeline selector1](https://github.com/RSSNext/assets/blob/main/timeline-selector1.png?raw=true) ![Timeline selector2](https://github.com/RSSNext/assets/blob/main/timeline-selector2.png?raw=true) - Support custom font and line height for content - Support custom date format for entry title date ![Custom content styles](https://github.com/RSSNext/assets/blob/main/custom-content-styles.png?raw=true) ## Improvements - Optimize translation display ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.5.md ================================================ # What's new in v0.3.5 ## New Features - Restore audio and notification views ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.3.6.md ================================================ # What's new in v0.3.6 ## New Features - Added a quick selector to the timeline column. ![](https://github.com/RSSNext/assets/raw/refs/heads/main/timeline-selector.mp4) ## Improvements ## Bug Fixes - Resolved the issue of being unable to reset it to empty after using the proxy. ================================================ FILE: apps/desktop/changelog/0.3.7.md ================================================ # What's new in v0.3.7 ## New Features - Revert to the previous list display styles. ![Timeline selector3](https://github.com/RSSNext/assets/blob/main/timeline-selector3.png?raw=true) - The entries of the list is now displayed directly in the timeline. ================================================ FILE: apps/desktop/changelog/0.3.8.md ================================================ # What's new in v0.3.8 ## New Features ## Improvements ## Bug Fixes - The app is stuck after closing the dialog - Can not sign in with email in the desktop app ================================================ FILE: apps/desktop/changelog/0.3.9.md ================================================ # What's new in v0.3.9 ## New Features ## Improvements ## Bug Fixes ================================================ FILE: apps/desktop/changelog/0.4.0.md ================================================ # What's New in v0.4.0 ## New Features - New global settings for AI summary and translation (#3294) - Display estimated audio duration for entry titles (#3292) - Simplify settings via an enhanced settings toggle (217e1a8) ## Improvements - Enhanced translation quality for entry content (#3294) - Refine toolbar customization (#3284) - Sign Windows executable files using SignPath (#3286) - Remove email verification toast notifications (9bb723a) - Restrict Zen mode display width (d107127) - Update MGC Icon to v1.36 (#3310) - Enhance UX for the withdraw modal (#3311) - Improve visibility and layout of the action settings button (0d5cb13) ## Bug Fixes - Fix reCAPTCHA being unclickable (305c4bc) - Correct store not updating after marking items as read in the list (e8305f8) ================================================ FILE: apps/desktop/changelog/0.4.1.md ================================================ # What's New in v0.4.1 ## New Features - Added a "Mark Above as Read" button at the bottom of the entry list (88963a9) ## Improvements - Enable linking social accounts with a different email (#3355) - Integrated a more advanced AI model for enhanced performance - Increased the number of invitation codes that users can create - Excluded entries without images from the picture view for a streamlined browsing experience ## Bug Fixes - Updated the icon for "View Source Content" to distinguish it from "Open in Browser" (#3373) - Fixed the problem of unread entries being incorrectly marked as read (#3305) - Corrected an issue where the modal's close button was unresponsive in certain scenarios (#3387) - Addressed a bug that prevented actions from being saved when empty (a997329) - Corrected an issue where unread statuses were not properly displayed in some instances - Fixed the timing of new content notifications to ensure they are sent only after the content is available ================================================ FILE: apps/desktop/changelog/0.4.2.md ================================================ # What's New in v0.4.2 ## Shiny new things - Added Cubox integration (#3385) - Included document links on the actions and discover pages (acdca33) ## Improvements - Optimized AI summary styles - Redesigned the search card in Discover; now displaying feed update time and frequency more intuitively - Revamped the header UI and interactions for a more concise, dynamic experience - Removed the mark-as-read confirmation (04e8a48) - Added pulse animation to the skeleton component (#3369) - Refined the sorting logic for RSSHub instances (a824343) - Enabled AI summary by default (8da8a71) - Enhanced the entry sharing copy by including both title and description (#3462) - Displayed text content within video modal preview (#3458) ## No longer broken - Fixed incorrect translation results and formatting (55524b) (#3421) - Restored the entry list header in the inbox - Resolved issues with masonry layout column adjustments failing in certain cases (#3425) - Hid the "open in browser" option when it is unavailable (ffada7c) - Corrected the inbox unread count to ensure it is up to date (82baf0b) - Prevented updates to the inbox unread count for read entries during deletion (c6024e3) - Fixed an issue where videos were not displayed correctly at times (935e39b) ================================================ FILE: apps/desktop/changelog/0.4.3.md ================================================ # What's New in v0.4.3 ## Shiny new things - Added server‑side readability with AI‑powered summaries and translation support (#3498) - You can now delete RSSHub instances (2d33a22) - Unread counts are displayed in the system tray (665221d) - Copy feed badge from the context menu (144f709) ## Improvements - Option to save AI summaries as the Cubox description (#3478) - Improved email validation and smoother reCAPTCHA closure on the sign‑in and sign‑up pages (#3510) - Added new action icons and updated the share and AI icons (f526edf, f447d6a, 34da9d2) - Enhanced security with one‑time tokens (#3482) - Translated titles and their original text now appear on separate lines (49f3cc2) - TTS action is hidden by default (3157ecf) ## No longer broken - Fixed TTS playback failure (a4d1653) - Fixed corner player not navigating to the correct entry (#1401) - Fixed issue that prevented saving to Readwise (53da938) - Fixed missing “Mark above as read” option in the picture masonry layout footer (e018736) - Fixed a potential login drop issue (0ee8510) ================================================ FILE: apps/desktop/changelog/0.4.4.md ================================================ # What's new in v0.4.4 ## Shiny new things - Added status action that allows you to send notifications or trigger webhooks for starred entries (9222553) ## Improvements - Refreshed color palette and numerous style tweaks for a cleaner look - Redesigned context and dropdown menus for faster, more intuitive navigation (c995da3) - Balances now display a maximum of two decimal places (#3564) ## No longer broken - Fixed an issue that stopped local settings from syncing with the cloud (7048aa9) - Corrected mis-redirects caused by malformed site URLs (2bbb00c) - Resolved tap-selection problems in entry lists on iPad Web (9da9dec) - Eliminated a crash in the Settings menu (5120c2c) ## Thanks Special thanks to external contributors @kovsu @Vixb1122 @johnsoncodehk for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.4.5.md ================================================ # What's new in v0.4.5 ## Shiny new things - Translation view toggle – choose between “translation-only” or bilingual display (#3568) - Mark Above/Below as Read option in the context menu (#3570) - Automation actions: auto start entries that match your rules (#3625) - Trending Feeds now spotlighted on the Discover page (eb6a409) - Timeline previews for feeds you haven’t subscribed to yet (eb26cde) - Invite-code gates removed – no invite code needed anymore (cee647c) ## Improvements - Seamless fallback to client-side readability if the server parser fails (e6aa58e) - Onboarding completely redesigned and streamlined (0357b7d) - Removed long-unmaintained language packs (f48697c) - Faster search performance - AI summaries now render Markdown (#3641) - Upgraded to React 19 and React Native 0.79 (#3661) - Cleaner category layout in the Discover page - Smoother sign-up / sign-in flow (e7f88b7) - Dozens of visual polish tweaks across the app ## No longer broken - Fixed occasional misplacement of the scroll indicator (87c9a48) - Font settings now apply correctly (5aed9a7) - Different views no longer clash with one another’s scroll positions (#3627) - Picture and video view sizes no longer interfere with each other (#3645) - Correct sorting icon in the feed list (#3675) - Custom fonts containing spaces now render properly (#3674) ## Thanks Special thanks to external contributors @kovsu @ericyzhu @cuikaipeng @grtsinry43 @cscnk52 for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.4.6.md ================================================ # What's new in v0.4.6 ## Shiny new things - Hide read subscriptions. You can now hide subscriptions with no unread items. Enable it in Settings → General → Subscriptions → Hide Read. (1b88d72) - Vim-style keyboard navigation with smarter focus detection: (262a7ce c2d8ffa) - Timeline focus: `j`/`k` or ↓/↑ to move between entries; `Enter` to open the selected entry. - Entry focus: `h`/`l` or ←/→ to jump to previous/next entry, `j`/`k` or ↓/↑ to scroll, `Esc` or `Backspace` to return to the Timeline. - Subscription list focus: `j`/`k` or ↓/↑ to move, `Enter` to open the Timeline, `Z` to collapse/expand a folder. ## Improvements - Clearer RSSHub error messages. (ed95fb6b) - AI summary is now enabled by default. (f329ae9) - Social-media view shows a richer action bar. (b06d8ff) - No more notification-permission prompts when your actions don’t need them. (450b759) - Discover page now remembers your language preference. (180933b) ## No longer broken - Fixed incorrect language display in Shiki code blocks. (20049b0) - Fixed occasional subscription-status errors. (9b0691a) - Fixed default view in the subscription dialog. (bf26a3e) - Fixed login dialog styling in dark mode. (e18f052) - Fixed duplicated action bar in social-media view. (a917231) ## Thanks Special thanks to external contributors @cleves0315 @grtsinry43 for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.4.8.md ================================================ # What's new in v0.4.8 ## Shiny new things - 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`. - Zotero Integration – connect with the popular open-source reference manager to streamline your research workflow (#3738) - Discover now includes the complete RSSHub route catalogue with a dedicated RSSHub category page featuring route search, trending routes, and popular examples (5f06de0) - Notifications settings – explore and test every available notification channel in one place (0db149f) - New option to hide private subscriptions in the Timeline: `Preferences → General → Subscription → Hide Private` (#3773) ## Improvements - Suscription form now shows each list’s subscriber count and last-updated time (e525edc) - Video view now displays the video’s total duration (2234b4b) - Added compatibility for both `folo` and `follow` URI schemes (4fa171b) ## No longer broken - Resolved blur effect not applying on the macOS Electron vibrancy layer (33ef0c4) - “More” label stays hidden when the entry-history limit isn’t reached (04583a3) - Fixed certain videos failing to render inside entries (c48b94a) ## Thanks Special thanks to external contributors @kovsu, @cscnk52, @cleves0315, @ericyzhu, @1411430556, and @ufec for their valuable contributions. ================================================ FILE: apps/desktop/changelog/0.5.0.md ================================================ # What's New in v0.5.0 ## Shiny new things - Double-clicking the draggable edge on either side of the entry list resets it to its default position (26c6853) - Brand-new Feed Manager panel (dbd43ae) - OPML import now offers a preview and lets you choose specific feeds to import (de6c2ff) - All-new Share sheet (5bbd96e) ## Improvements - Switched to hCaptcha for a smoother human-verification experience (22aec92) - Gradually rolling out an experimental unified local database for mobile and desktop (#3809) - Implemented smooth scrolling (6c73ae5) - Significant overall performance boost - AI will skip summarising extra-short articles to avoid trivial summaries (852fb1d) - Added “Reset to defaults” button to the shortcuts page (#3834) - Added a border around 2-factor QR codes for better scan reliability (f7faf78) - RSSHub routes top-feed list now respects your Discover-language setting (14d4da3) - Audio player now prefers episode artwork over feed artwork (#3855) - You can now manually paste your login token to bypass Microsoft Store deeplink issues (47e07a4) - Added “Check for updates” button in Settings (ef304cd) ## No longer broken - Dropdown menus now keep keyboard focus when opened (f31d43d) - “Mark as read” button is now perfectly centred (#3836) - Entry list no longer displays stale cached items (0a167ac) - Squashed numerous shortcut-key bugs ## Thanks Special thanks to external contributors @ericyzhu @kovsu @yeeway0609 @cscnk52 for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.6.0.md ================================================ # What's New in v0.6.0 This version has been withdrawn. ================================================ FILE: apps/desktop/changelog/0.6.1.md ================================================ # What's New in v0.6.1 ## Shiny New Things - Import and export your Actions (394d00f) - Add a bio, website, and social links to your profile (507a525) - Upload a profile picture - Use video duration as an Action condition ## Improvements - A snazzy new look for your personal profile - Redesigned the Actions page (1ace5ea) - Redesigned the RSSHub page (f9aca60) - Added length limits to certain profile fields - Simplified default commands in the entry tool (85122fb) - Enhanced UI labels and descriptions for clarity (2ed9f70) - Gradually rolling out an experimental unified local database for mobile and desktop (#3897 #3902) - Polished image-preview styling (cf72753) - Refined toast notifications (73f8011) ## No Longer Broken - More reliable automatic recovery after database-migration failures (c2e0c3d) - Fixed unread counts not clearing in the macOS Docker build (70255af) - Fixed old entries showing during initial load (24ae065) - Fixed handling of links starting with `.` (de8eac8) - Fixed text-to-speech not working (82952b0) - Fixed star/unstar status not syncing across devices (fbd0b3) ## Thanks Special thanks to volunteer contributors @kovsu @huanfe1 @cscnk52 @Olexandr88 @0-o0 @kingsword09 @ericyzhu for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.6.2.md ================================================ # What's new in v0.6.2 ## Shiny new things - New “Fade Read Items” option dims read entries in the timeline (eeeea47) - YouTube videos now play directly in the Articles view (#4096) - Choose your own accent color for the interface (47129d5) ## Improvements - Clearer podcast duration display (#4001) - Added a Sign In / Sign Up toggle on the email page (81c544a) - Smarter visibility logic for the picture action bar (#4033) - Edit Feed now shows only categories relevant to the current view (#4094) - “Mark above as read” button no longer appear in the Starred list (#4083) - RSSHub and Trending queries persist between sessions (7a95a15) - Refined profile layout (#4101) ## No longer broken - Discover page scroll indicator now can reach 100% (#3962) - “Mark as Read” keyboard shortcut works again (24113f7) - Fixed infinite scrolling when dragging in Customize Toolbar settings (#4018) - Fixed the bug that you can’t mark an entry as Unread (8ea13a3) - Clicking Read more in Social Media view no longer refreshes the page (#4027) - Entries with special characters in the title can be saved to Obsidian (f6cff2a) - Image downloads are now reliable (#4095) - Corrected Inbox “Mark as Read” logic (7c1bf99) - Prevented feed click event from bubbling to route item (#4113) - Fixed the bug that source content not triggered automatically (c5edc76) ## Thanks Special thanks to volunteer contributors @ericyzhu @cscnk52 @kovsu @Fatpandac @dai @LitoMore @kira-offgrid @AkaShark @RtYkk for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.6.3.md ================================================ # What's new in v0.6.3 ## Shiny new things - Hide From Timeline option for subscriptions to make your timeline cleaner. - BitTorrent attachment support and qBittorrent integration. ## Improvements - Clearer loading indicators for both the entry list and the subscription list (e81220d) - The hyperlink in the article preview has been added with an emphasis color. (db67ec8) - Search results now persist across navigation. - Sort feed by subscription count or update per week in feed management. - Notification support for inbox. ## No longer broken - Fresh entries are no longer incorrectly auto‑marked as read after a list refresh. (d3d1e05) - Fixed the timing of masonry column width calculations to avoid layout glitches. (17389d9) - Removed placeholders in the picture waterfall view to prevent incorrectly layout redraws. (3c4b152) - Corrected initial height calculation for items in the picture waterfall view. (82a3537) - Fixed the Microsoft Store build not being recognized by deep links. (943ef31) - Right-click context menu shortcut can't trigger the context menu. (b44bf71) - Text in the social media view is now selectable. - Translation settings can not be toggled. - Fixed can not mark read/unread for starred entries. - Feeds with invalid site URLs being hidden in the feed list. - List with unread entries being hidden when Hide Read option is enabled. - Inbox can not receive text only emails like Gmail Forward verification. ## Thanks Special thanks to volunteer contributors @kovsu, @cscnk52, @yansq, @hellosunghyun for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.7.0.md ================================================ # What's new in v0.7.0 ## Shiny new things - Add custom integration configurations to adapt to more apps ## Improvements - Redesign integration settings page for better user experience and support integration settings export and import. ## No longer broken ## Thanks Special thanks to volunteer contributors @ for their valuable contributions ================================================ FILE: apps/desktop/changelog/0.8.0.md ================================================ # What's new in v0.8.0 ## Shiny new things - Smart onboarding that gets you started in seconds. - One place for all your feeds. - A cleaner, consistent reading experience for social media posts, picture galleries, videos, and articles. - Support subtitles for podcasts and videos. - Redesigned Actions for easier setup. ## Improvements - We have made countless improvements and bug fixes in this version. ================================================ FILE: apps/desktop/changelog/0.9.0.md ================================================ # What's new in v0.9.0 ## Improvements - Removed the limit on the maximum number of views (b69a935) - Allow hiding the “All” view (0882e47) - Introduced a right-click menu for views, allowing quick access to hide or open settings (60dcd42) - Added a smooth sliding animation when opening entry details (1720ebb) ## No longer broken - Fixed an issue where the scrollbar didn’t reset when switching between entry details (21124f5) ================================================ FILE: apps/desktop/changelog/1.0.0.md ================================================ # What's new in v1.0.0 ## Shiny new things - 🌟 **Folo is now the AI Reader** — a smarter way to follow everything. ## Improvements - Added support for multilingual `params` in YouTube video previews (#4610) - Redirects now go to your configured first view instead of always defaulting to All (a82b0e9) ## No longer broken - Fixed the hover indicator style on the audio player progress bar (#4600) - Fixed video autoplay in media previews (#4531) ## Thanks Special thanks to volunteer contributors @yeeway0609 @kovsu @unixzii for their valuable contributions ================================================ FILE: apps/desktop/changelog/1.1.0.md ================================================ # What's new in v1.1.0 ## Shiny new things - Reintroduced the clean, efficient three-column layout for a smoother reading experience. - Added out-of-the-box Fabric integration for MCP users. ## Improvements - Automatically generate chat titles during live sessions. - Simplified AI usage metrics display with improved styling. - Added a progress bar to the onboarding feed subscription step. - Adopted a more reliable mechanism for checking software updates. - Windows now starts minimized to the system tray by default. - Timeline AI summaries now trigger automatically in all views. - Simplified the AI reasoning display for better clarity. - Onboarding flow now allows manual skipping. - Merged AI chat history and AI task history into a single dropdown for easier access. ## No longer broken - Fixed an issue where the Windows EXE software update check failed to run properly. - Fixed a bug preventing category movement in the “All” view. - Fixed occasional AI chat interruptions under certain conditions. ## Thanks Special thanks to volunteer contributors @kovsu for their valuable contributions ================================================ FILE: apps/desktop/changelog/1.2.2.md ================================================ # What's new in v1.2 ## Shiny new things - **AI** - Added **BYOK (Bring Your Own Key)** support — choose your own AI provider freely. - Improved **AI Memory**: you can now update or refine past memories whenever you need. - Reduced the “guiding tone” in **AI Summary** so summaries feel more natural and personal. - Introduced **AI Timeline Sort (Beta)**: your timeline can now rearrange itself based on your reading preferences. ![](https://cdn.follow.is/ai-memory.mp4) ![](https://cdn.follow.is/share.mp4) - **UI** - Several UI components have been refined with an improved design system for a cleaner, more consistent feel. - **Subscription** - Added a new **Basic Plan (non-AI version)** for users who prefer a lighter subscription option. - **Selection** - Added **underline sharing** and **Ask AI** directly from selected text to make reading actions smoother. ## Improvements - **Feed** - RSSHub subscriptions now include a clear identifier, making source management easier. - **Web** - You can now browse recommended content without logging in — explore before committing. - **Onboarding** - The onboarding flow has been fully redesigned for a smoother, more intuitive first-time experience. - **Entry** - Entries now come with AI-generated labels to help you sort and revisit content effortlessly. ## No longer broken We fixed several bugs to make everything feel more stable and reliable. ================================================ FILE: apps/desktop/changelog/1.2.6.md ================================================ # What's new in v1.2.6 ## Shiny new things - Optimize Stripe subscription management UI with dynamic billing portal and status indicators ## Improvements - Update AI SDK to v6.0.5 - Refresh translation cache on mode changes ## No longer broken - Fix AI chat message types alignment - Fix default custom integration fetch ## Thanks Special thanks to volunteer contributors @TonyRL for their valuable contributions ================================================ FILE: apps/desktop/changelog/1.3.0.md ================================================ # What's new in v1.3.0 ## Shiny new things - Markdown link supports open share feed link directly in the app ## No longer broken - Fix preserve session when switching API domain - Fix handle invalid URL parsing in useFeedSafeUrl hook - Fix shortcuts page freeze - Fix icon service URL and image proxy URL ## Thanks Special thanks to volunteer contributors @cuikaipeng @yjl9903 for their valuable contributions ================================================ FILE: apps/desktop/changelog/1.3.1.md ================================================ # What's new in v1.3.1 ## Shiny new things - Supported French localization. ## Improvements - Migrated error tracking to PostHog for better diagnostics. - Improved Japanese localization coverage. ## No longer broken - Hardened external protocol handling to prevent potential security issues. - Fixed sorting crash when importing subscriptions with missing titles. ## Thanks Special thanks to volunteer contributors @Kowyo and @q1uf3ng for their valuable contributions ================================================ FILE: apps/desktop/changelog/1.4.0.md ================================================ # What's new in v1.4.0 ## Shiny new things - Added in-app review prompts ## Improvements - Expanded desktop end-to-end coverage for auth and user flows ## No longer broken - Removed the unwanted text selection toolbar - Fixed AI onboarding asset loading by switching the spline asset domain - Hardened setting sync authentication lifecycle ## Thanks Special thanks to volunteer contributors for their valuable contributions ================================================ FILE: apps/desktop/changelog/next.md ================================================ # What's new in vNEXT_VERSION ## Shiny new things ## Improvements ## No longer broken ## Thanks Special thanks to volunteer contributors @ for their valuable contributions ================================================ FILE: apps/desktop/changelog/next.template.md ================================================ # What's new in vNEXT_VERSION ## Shiny new things ## Improvements ## No longer broken ## Thanks Special thanks to volunteer contributors @ for their valuable contributions ================================================ FILE: apps/desktop/configs/vite.electron-render.config.ts ================================================ import { fileURLToPath } from "node:url" import { dirname, resolve } from "pathe" import type { UserConfig } from "vite" import { routeBuilderPlugin } from "vite-plugin-route-builder" import { cleanupUnnecessaryFilesPlugin } from "../plugins/vite/cleanup" import { createPlatformSpecificImportPlugin } from "../plugins/vite/specific-import" import { viteRenderBaseConfig } from "./vite.render.config" const root = resolve(fileURLToPath(dirname(import.meta.url)), "..") const VITE_ROOT = resolve(root, "layer/renderer") const mode = process.argv.find((arg) => arg.startsWith("--mode"))?.split("=")[1] const isStaging = mode === "staging" export default { ...viteRenderBaseConfig, plugins: [ ...viteRenderBaseConfig.plugins, createPlatformSpecificImportPlugin("electron"), routeBuilderPlugin({ pagePattern: "src/pages/**/*.tsx", outputPath: "src/generated-routes.ts", enableInDev: true, }), cleanupUnnecessaryFilesPlugin([ "og-image.png", "icon-512x512.png", "opengraph-image.png", "favicon.ico", "icon-192x192.png", "favicon-dev.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "pwa-64x64.png", "pwa-192x192.png", "pwa-512x512.png", ]), ], root: VITE_ROOT, build: { outDir: resolve(root, "dist/renderer"), sourcemap: isStaging || !!process.env.CI, target: "esnext", rollupOptions: { input: { main: resolve(VITE_ROOT, "index.html"), }, }, minify: !isStaging, }, define: { ...viteRenderBaseConfig.define, ELECTRON: "true", }, } satisfies UserConfig ================================================ FILE: apps/desktop/configs/vite.render.config.ts ================================================ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import react from "@vitejs/plugin-react" import { codeInspectorPlugin } from "code-inspector-plugin" import { dirname, resolve } from "pathe" import { prerelease } from "semver" import type { UserConfig } from "vite" import { getGitHash } from "../../../scripts/lib" import { astPlugin } from "../plugins/vite/ast" import { circularImportRefreshPlugin } from "../plugins/vite/hmr" import { customI18nHmrPlugin } from "../plugins/vite/i18n-hmr" import { localesJsonPlugin } from "../plugins/vite/locales-json" import i18nCompleteness from "../plugins/vite/utils/i18n-completeness" const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") const pkg = JSON.parse(readFileSync(resolve(pkgDir, "./package.json"), "utf8")) const getChangelogFileContent = () => { const { version: pkgVersion } = pkg const isDev = process.env.NODE_ENV === "development" // get major-minor-patch, e.g. 0.2.0-beta.2 -> 0.2.0 const version = pkgVersion.split("-")[0] try { return readFileSync(resolve(pkgDir, "./changelog", `${isDev ? "next" : version}.md`), "utf8") } catch { return "" } } const changelogFile = getChangelogFileContent() export const viteRenderBaseConfig = { worker: { format: "es", }, optimizeDeps: { exclude: ["sqlocal", "wa-sqlite", "@follow-app/client-sdk"], }, resolve: { alias: { "~": resolve("layer/renderer/src"), "@pkg": resolve("package.json"), "@locales": resolve("../../locales"), "@follow/electron-main": resolve("layer/main/src"), }, }, base: "/", plugins: [ { name: "import-sql", transform(code, id) { if (id.endsWith(".sql")) { const json = JSON.stringify(code) .replaceAll("\u2028", "\\u2028") .replaceAll("\u2029", "\\u2029") return { code: `export default ${json}`, } } }, }, localesJsonPlugin(), codeInspectorPlugin({ bundler: "vite", hotKeys: ["altKey"], }), react({ // jsxImportSource: "@welldone-software/why-did-you-render", // <----- }), circularImportRefreshPlugin(), astPlugin, customI18nHmrPlugin(), ], define: { APP_VERSION: JSON.stringify(pkg.version), APP_NAME: JSON.stringify(pkg.productName), APP_DEV_CWD: JSON.stringify(process.cwd()), GIT_COMMIT_SHA: JSON.stringify(process.env.VERCEL_GIT_COMMIT_SHA || getGitHash()), RELEASE_CHANNEL: JSON.stringify((prerelease(pkg.version)?.[0] as string) || "stable"), DEBUG: process.env.DEBUG === "true", I18N_COMPLETENESS_MAP: JSON.stringify({ ...i18nCompleteness, en: 100 }), CHANGELOG_CONTENT: JSON.stringify(changelogFile), "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), }, } satisfies UserConfig ================================================ FILE: apps/desktop/dev-only/dev-app-update.yml ================================================ provider: custom channel: latest ================================================ FILE: apps/desktop/e2e/playwright.config.ts ================================================ import { defineConfig, devices } from "@playwright/test" import { resolveDesktopE2EEnv } from "./support/env" const env = resolveDesktopE2EEnv() export default defineConfig({ testDir: "./tests", fullyParallel: false, workers: 1, timeout: 120_000, expect: { timeout: 15_000, }, reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], outputDir: "test-results", use: { baseURL: env.webBaseURL, trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", serviceWorkers: "block", }, webServer: { command: "pnpm run dev:web", cwd: env.desktopAppDir, env: { ...process.env, VITE_API_URL: process.env.FOLO_E2E_WEB_DEV_API_URL ?? env.apiURL, VITE_WEB_URL: process.env.FOLO_E2E_WEB_DEV_WEB_URL ?? env.webURL, }, url: env.webDevServerURL, timeout: 120_000, reuseExistingServer: !process.env.CI, }, projects: [ { name: "web", testMatch: /tests\/web\/.*\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chromium", ignoreHTTPSErrors: true, launchOptions: { args: ["--disable-web-security"], }, }, }, { name: "electron", testMatch: /tests\/electron\/.*\.spec\.ts/, }, ], }) ================================================ FILE: apps/desktop/e2e/scripts/capture-ui-audit.ts ================================================ import { mkdir } from "node:fs/promises" import { chromium } from "@playwright/test" import { join } from "pathe" import { createTestAccount, tryDeleteCurrentUser } from "../support/account" import { closeSettings, dismissFeedForm, followOnboardingFeed, openSettings, openWebApp, } from "../support/app" import { bootstrapAuthenticatedWebSession } from "../support/auth-bootstrap" import { buildWebAppURL, resolveDesktopE2EEnv } from "../support/env" const SETTING_TABS = [ "general", "appearance", "notifications", "shortcuts", "ai", "integration", "feeds", "list", "profile", "data-control", "cli", "plan", "about", ] as const const SUBVIEW_ROUTES = ["discover", "power", "action", "rsshub", "ai"] as const const waitForUiSettled = async (page: import("@playwright/test").Page, delay = 1200) => { await page.waitForLoadState("domcontentloaded") await page.waitForTimeout(delay) } const waitForRouteReady = async ( page: import("@playwright/test").Page, route: (typeof SUBVIEW_ROUTES)[number], ) => { await waitForUiSettled(page, route === "power" ? 3500 : 1200) if (route === "power") { await page .waitForFunction( () => document.body.textContent?.includes("Your Balance") || document.body.textContent?.includes("Transactions") || document.body.textContent?.includes("Create Wallet"), undefined, { timeout: 15_000 }, ) .catch(() => {}) } } async function main() { const env = resolveDesktopE2EEnv() const outputDir = join( env.desktopAppDir, "e2e", "artifacts", "ui-audit", `run-${new Date().toISOString().replaceAll(":", "-")}`, ) await mkdir(outputDir, { recursive: true }) const browser = await chromium.launch({ channel: "chromium", headless: true, args: ["--disable-web-security"], }) const context = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 980, }, colorScheme: "light", }) let page = await context.newPage() const account = createTestAccount("ui-audit") let screenshotIndex = 1 const capture = async (name: string) => { const path = join(outputDir, `${String(screenshotIndex).padStart(2, "0")}-${name}.png`) screenshotIndex += 1 await page.screenshot({ path, fullPage: false }) console.info(path) } const bootstrapAccount = async () => { for (let attempt = 1; attempt <= 3; attempt += 1) { try { await bootstrapAuthenticatedWebSession(page, env, account) return } catch (error) { await capture(`auth-bootstrap-attempt-${attempt}-failed`) if (attempt === 3) { throw error } await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) await waitForUiSettled(page) } } } try { await openWebApp(page, env) await waitForUiSettled(page) await capture("00-login-modal") await page.close() page = await context.newPage() await bootstrapAccount() await waitForUiSettled(page) await capture("01-home-articles") await followOnboardingFeed(page, env) await waitForUiSettled(page) await capture("02-discover-follow") await dismissFeedForm(page) const timelineTabs = await page.locator('[data-testid^="timeline-tab-"]').all() for (const tab of timelineTabs) { const testId = await tab.getAttribute("data-testid") if (!testId) continue await tab.click() await waitForUiSettled(page) await capture(`timeline-${testId.replace("timeline-tab-", "")}`) } for (const route of SUBVIEW_ROUTES) { await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) await waitForRouteReady(page, route) await capture(`subview-${route}`) } await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) await waitForUiSettled(page) await openSettings(page) await waitForUiSettled(page) for (const tab of SETTING_TABS) { if (tab === "general") { await capture("settings-general") continue } const tabTrigger = page.getByTestId(`settings-tab-${tab}`) if (!(await tabTrigger.isVisible().catch(() => false))) { continue } await tabTrigger.click() await waitForUiSettled(page) await capture(`settings-${tab}`) } await closeSettings(page) await waitForUiSettled(page) await capture("home-after-settings") } finally { await tryDeleteCurrentUser(page, env).catch(() => null) await context.close().catch(() => {}) await browser.close().catch(() => {}) } } void main() ================================================ FILE: apps/desktop/e2e/support/account.ts ================================================ import type { Page } from "@playwright/test" import type { DesktopE2EEnv } from "./env" export interface TestAccount { email: string password: string } export const createTestAccount = (name: string): TestAccount => { const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return { email: `folo-e2e-${name}-${suffix}@example.com`, password: process.env.FOLO_E2E_PASSWORD ?? "Password123!", } } export const tryDeleteCurrentUser = async (page: Page, env: DesktopE2EEnv) => { return page.evaluate(async ({ apiURL }) => { try { const response = await fetch(`${apiURL}/better-auth/delete-user-custom`, { method: "POST", credentials: "include", headers: { "content-type": "application/json", }, body: JSON.stringify({}), }) return { ok: response.ok, status: response.status, text: await response.text(), } } catch (error) { return { ok: false, status: -1, text: error instanceof Error ? error.message : String(error), } } }, env) } ================================================ FILE: apps/desktop/e2e/support/app.ts ================================================ import type { Locator, Page } from "@playwright/test" import { expect } from "@playwright/test" import type { TestAccount } from "./account" import type { DesktopE2EEnv } from "./env" import { buildWebAppURL } from "./env" const ONBOARDING_FEED_URL = "folo://onboarding" const isVisible = async (locator: Locator) => locator.isVisible().catch(() => false) const visibleByTestId = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]:visible`).last() export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => { await page.addInitScript( (nextEnv) => { window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token" const originalFetch = globalThis.fetch.bind(globalThis) const authEndpoints = [ "/better-auth/sign-in/email", "/better-auth/sign-up/email", "/better-auth/forget-password", ] globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init) const requestURL = new URL(request.url, globalThis.location.origin) const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path)) if (!shouldInjectToken) { return originalFetch(input, init) } const headers = new Headers(request.headers) if (!headers.has("x-token")) { headers.set("x-token", "r3:e2e-token") } return originalFetch(new Request(request, { headers })) } if (!nextEnv) { return } const fixedEnv = { VITE_API_URL: nextEnv.apiURL, VITE_EXTERNAL_API_URL: nextEnv.apiURL, VITE_WEB_URL: nextEnv.webURL, } const target = (globalThis as typeof globalThis & { __followEnv?: Record<string, string> }).__followEnv ?? {} const proxy = new Proxy(target, { get(currentTarget, property, receiver) { if (typeof property === "string" && property in fixedEnv) { return fixedEnv[property as keyof typeof fixedEnv] } return Reflect.get(currentTarget, property, receiver) }, set(currentTarget, property, value, receiver) { if (typeof property === "string" && property in fixedEnv) { return true } return Reflect.set(currentTarget, property, value, receiver) }, ownKeys(currentTarget) { return Array.from(new Set([...Reflect.ownKeys(currentTarget), ...Object.keys(fixedEnv)])) }, getOwnPropertyDescriptor(currentTarget, property) { if (typeof property === "string" && property in fixedEnv) { return { configurable: true, enumerable: true, writable: false, value: fixedEnv[property as keyof typeof fixedEnv], } } return Reflect.getOwnPropertyDescriptor(currentTarget, property) }, }) Object.defineProperty(globalThis, "__followEnv", { configurable: true, enumerable: false, get() { return proxy }, set() {}, }) }, env ? { apiURL: env.apiURL, webURL: env.webURL } : undefined, ) } export const openWebApp = async (page: Page, env: DesktopE2EEnv, route = "/") => { await injectRecaptchaToken(page, env) await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) } export const waitForAuthenticated = async (page: Page) => { const isAuthenticatedUiReady = async () => { const profileVisible = await page .getByTestId("profile-menu-trigger") .isVisible() .catch(() => false) const loginModalVisible = await page .getByTestId("login-modal") .isVisible() .catch(() => false) return profileVisible && !loginModalVisible } await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true) } export const waitForLoggedOut = async (page: Page) => { await expect .poll( async () => { const loginButtonVisible = await page .getByTestId("login-button") .last() .isVisible() .catch(() => false) const loginModalVisible = await page .getByTestId("login-modal") .last() .isVisible() .catch(() => false) const loginInputVisible = await page .getByTestId("login-email-input") .last() .isVisible() .catch(() => false) const registerInputVisible = await page .getByTestId("register-email-input") .last() .isVisible() .catch(() => false) return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible }, { timeout: 30_000 }, ) .toBe(true) } export const ensureLoginModal = async (page: Page) => { await expect .poll( async () => { const loginModalVisible = await page .getByTestId("login-modal") .last() .isVisible() .catch(() => false) const loginButtonVisible = await page .getByTestId("login-button") .last() .isVisible() .catch(() => false) const loginInputVisible = await page .getByTestId("login-email-input") .last() .isVisible() .catch(() => false) const registerInputVisible = await page .getByTestId("register-email-input") .last() .isVisible() .catch(() => false) return loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible }, { timeout: 30_000 }, ) .toBe(true) } const ensureCredentialForm = async (page: Page, mode: "register" | "login") => { await ensureLoginModal(page) const targetInput = visibleByTestId( page, mode === "register" ? "register-email-input" : "login-email-input", ) const loginButton = visibleByTestId(page, "login-button") const loginModal = visibleByTestId(page, "login-modal") const activeDialog = page.locator('[role="dialog"]:visible').last() const credentialProvider = visibleByTestId(page, "login-provider-credential") const targetForm = visibleByTestId(page, mode === "register" ? "register-form" : "login-form") const oppositeForm = visibleByTestId(page, mode === "register" ? "login-form" : "register-form") const oppositeFormSwitcher = visibleByTestId( page, mode === "register" ? "login-switch-register" : "register-switch-login", ) if (await isVisible(targetInput)) { return } if ( (await isVisible(loginButton)) && !(await isVisible(loginModal)) && !(await isVisible(activeDialog)) ) { await expect(loginButton).toBeVisible({ timeout: 30_000 }) await loginButton.click({ noWaitAfter: true }) } if (await isVisible(targetInput)) { return } if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) { await expect(credentialProvider).toBeVisible({ timeout: 30_000 }) await credentialProvider.click({ timeout: 30_000, noWaitAfter: true }) await expect .poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), { timeout: 30_000, }) .toBe(true) } if (await isVisible(oppositeForm)) { await expect(oppositeFormSwitcher).toBeVisible({ timeout: 30_000 }) await oppositeFormSwitcher.click({ timeout: 30_000, noWaitAfter: true }) } await expect(targetInput).toBeVisible({ timeout: 30_000 }) } export const registerWithCredential = async (page: Page, account: TestAccount) => { await ensureCredentialForm(page, "register") await visibleByTestId(page, "register-email-input").fill(account.email) await visibleByTestId(page, "register-password-input").fill(account.password) const confirmPasswordInput = visibleByTestId(page, "register-confirm-password-input") await confirmPasswordInput.fill(account.password) const submit = visibleByTestId(page, "register-submit") await expect(submit).toBeEnabled({ timeout: 30_000 }) await submit.click() await waitForAuthenticated(page) } export const loginWithCredential = async (page: Page, account: TestAccount) => { await ensureCredentialForm(page, "login") await visibleByTestId(page, "login-email-input").fill(account.email) const passwordInput = visibleByTestId(page, "login-password-input") await passwordInput.fill(account.password) const submit = visibleByTestId(page, "login-submit") await expect(submit).toBeEnabled({ timeout: 30_000 }) await submit.click() await waitForAuthenticated(page) } export const logoutFromProfileMenu = async (page: Page) => { await page.keyboard.press("Escape").catch(() => {}) if (page.url().startsWith("app://")) { await returnToMainShell(page) } await page.getByTestId("profile-menu-trigger").click() const signOutResponse = page .waitForResponse( (response) => response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"), { timeout: 30_000 }, ) .catch(() => null) await page.getByTestId("profile-menu-logout").click() await signOutResponse await waitForLoggedOut(page) } const waitForMainShell = async (page: Page) => { await expect .poll( async () => { const profileVisible = await page .getByTestId("profile-menu-trigger") .isVisible() .catch(() => false) const timelineVisible = await page .getByTestId("timeline-tab-articles") .isVisible() .catch(() => false) return profileVisible || timelineVisible }, { timeout: 30_000 }, ) .toBe(true) } const returnToMainShell = async (page: Page) => { const discoverInput = page.getByTestId("discover-form-input") if (await discoverInput.isVisible().catch(() => false)) { const backButton = page.getByTestId("subview-back") if (await backButton.isVisible().catch(() => false)) { await backButton.click() } else { await page.keyboard.press("Escape").catch(() => {}) } if (await discoverInput.isVisible().catch(() => false)) { await page.keyboard.press("Escape").catch(() => {}) } await expect .poll(async () => discoverInput.isVisible().catch(() => false), { timeout: 15_000 }) .toBe(false) } await waitForMainShell(page) const activeDialog = page.locator('[role="dialog"]:visible').last() if (await activeDialog.isVisible().catch(() => false)) { await page.keyboard.press("Escape").catch(() => {}) if (await activeDialog.isVisible().catch(() => false)) { const modalClose = activeDialog.getByTestId("modal-close").first() if (await modalClose.isVisible().catch(() => false)) { await modalClose.click().catch(() => {}) } } await expect .poll(async () => activeDialog.isVisible().catch(() => false), { timeout: 10_000 }) .toBe(false) } } const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => { if (tab === "general") { await expect(page.getByTestId("settings-language-select")).toBeVisible({ timeout: 15_000 }) return } await expect .poll(async () => page.locator('[data-testid^="settings-feed-row-"]').count(), { timeout: 15_000, }) .toBeGreaterThan(0) } export const openSettings = async (page: Page, tab: "general" | "feeds" = "general") => { await waitForAuthenticated(page) const settingsModal = page.locator("#setting-modal").first() const openSettingsFromMenu = async () => { await returnToMainShell(page) const profileTrigger = page.getByTestId("profile-menu-trigger") await expect(profileTrigger).toBeVisible({ timeout: 15_000 }) await profileTrigger.click() const preferencesItem = page.getByTestId("profile-menu-preferences") await expect(preferencesItem).toBeVisible({ timeout: 15_000 }) await preferencesItem.click() await expect(settingsModal).toBeVisible({ timeout: 15_000 }) } if (!(await settingsModal.isVisible().catch(() => false))) { try { await openSettingsFromMenu() } catch { await openSettingsFromMenu() } } await openSettingsTab(page, tab) } export const openSettingsTab = async (page: Page, tab: "general" | "feeds") => { const settingsTab = page.getByTestId(`settings-tab-${tab}`) await expect(settingsTab).toBeVisible({ timeout: 15_000 }) if (tab === "feeds") { await expect .poll( async () => { const className = (await settingsTab.getAttribute("class")) ?? "" return !className.includes("opacity-50") }, { timeout: 15_000 }, ) .toBe(true) } await settingsTab.click() await waitForSettingsTabContent(page, tab) } export const closeSettings = async (page: Page) => { const settingsModal = page.locator("#setting-modal").first() if (!(await settingsModal.isVisible().catch(() => false))) { return } await page.keyboard.press("Escape").catch(() => {}) if (await settingsModal.isVisible().catch(() => false)) { const modalClose = settingsModal.getByTestId("modal-close").first() if (await isVisible(modalClose)) { await modalClose.click().catch(() => {}) } } await expect .poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 }) .toBe(false) } export const setLanguage = async (page: Page, label: string) => { await page.getByTestId("settings-language-select").click() await page.getByRole("option", { name: label }).click() } export const getLanguageLabel = async (page: Page) => { return page.getByTestId("settings-language-select").textContent() } export const openOnboardingFeedForm = async ( page: Page, _env?: DesktopE2EEnv, _options?: { electron?: boolean }, ) => { const discoverInput = page.getByTestId("discover-form-input") if (!(await discoverInput.isVisible().catch(() => false))) { await returnToMainShell(page) const discoverTrigger = page.getByTestId("subscription-discover-trigger") await expect(discoverTrigger).toBeVisible({ timeout: 15_000 }) await discoverTrigger.click() } await expect(discoverInput).toBeVisible({ timeout: 15_000 }) await discoverInput.fill(ONBOARDING_FEED_URL) await discoverInput.press("Enter") await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 }) } export const followOnboardingFeed = async ( page: Page, env: DesktopE2EEnv, options?: { electron?: boolean }, ) => { await openOnboardingFeedForm(page, env, options) const onboardingDiscoverCard = page .locator("[data-feed-id]") .filter({ hasText: "Welcome to Folo" }) .first() const followButton = onboardingDiscoverCard.getByRole("button", { name: /^Follow$/i }) if (await followButton.isVisible().catch(() => false)) { await expect(followButton).toBeEnabled({ timeout: 15_000 }) await followButton.click() } await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 }) } export const dismissFeedForm = async (page: Page) => { const cancelButton = visibleByTestId(page, "feed-form-cancel") const dialog = page.locator('[role="dialog"]').last() if (!(await cancelButton.isVisible().catch(() => false))) { if (await dialog.isVisible().catch(() => false)) { await page.keyboard.press("Escape").catch(() => {}) } return } await cancelButton.click() if ( (await cancelButton.isVisible().catch(() => false)) || (await dialog.isVisible().catch(() => false)) ) { await page.keyboard.press("Escape").catch(() => {}) } } const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => { const targetedFeedRow = onboardingFeedId ? page.getByTestId(`settings-feed-row-${onboardingFeedId}`) : null const fallbackFeedRow = page .locator('[data-testid^="settings-feed-row-"]') .filter({ hasText: "Welcome to Folo", }) .first() const settingsViewport = page.locator("#setting-modal [data-radix-scroll-area-viewport]").first() await settingsViewport .evaluate((element) => { if (element instanceof HTMLElement) { element.scrollTop = 0 } }) .catch(() => {}) for (let attempt = 0; attempt < 24; attempt++) { if (targetedFeedRow && (await targetedFeedRow.isVisible().catch(() => false))) { return targetedFeedRow } if (await fallbackFeedRow.isVisible().catch(() => false)) { return fallbackFeedRow } await settingsViewport.hover().catch(() => {}) await page.mouse.wheel(0, 1200) await page.waitForTimeout(150) } return targetedFeedRow && (await targetedFeedRow.count()) > 0 ? targetedFeedRow : fallbackFeedRow } export const unsubscribeFirstFeedFromSettings = async (page: Page, _env?: DesktopE2EEnv) => { const onboardingFeedItem = page .locator("[data-feed-id]") .filter({ hasText: "Welcome to Folo", }) .first() const onboardingFeedId = (await onboardingFeedItem.count()) > 0 ? await onboardingFeedItem.getAttribute("data-feed-id") : null await openSettings(page) await openSettingsTab(page, "feeds") const feedRow = await findSettingsFeedRow(page, onboardingFeedId) await expect(feedRow).toBeVisible({ timeout: 15_000 }) await feedRow.scrollIntoViewIfNeeded().catch(() => {}) const feedRowTestId = await feedRow.getAttribute("data-testid") await feedRow.click() const unsubscribeButton = page.getByTestId("feeds-batch-unsubscribe") await expect(unsubscribeButton).toBeVisible({ timeout: 15_000 }) await unsubscribeButton.click() await page.getByTestId("confirm-destroy").click() if (feedRowTestId) { await expect(page.getByTestId(feedRowTestId)).toHaveCount(0, { timeout: 15_000 }) } else { await expect(feedRow).toHaveCount(0, { timeout: 15_000 }) } } export const expectOnboardingFeedUnsubscribed = async ( page: Page, _env?: DesktopE2EEnv, _options?: { electron?: boolean }, ) => { await openOnboardingFeedForm(page) await expect(page.getByTestId("feed-form-cancel")).toHaveCount(0) } export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => { await returnToMainShell(page) const videosTab = page.getByTestId("timeline-tab-videos") await videosTab.click() await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) const articlesTab = page.getByTestId("timeline-tab-articles") await articlesTab.click() await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) const unreadOnboardingEntry = page .locator('[data-entry-id][data-read="false"]:visible') .filter({ has: page.locator("a[href]") }) .first() await expect(unreadOnboardingEntry).toBeVisible({ timeout: 15_000 }) const onboardingEntryId = await unreadOnboardingEntry.getAttribute("data-entry-id") expect(onboardingEntryId).toBeTruthy() const onboardingEntry = page.locator(`[data-entry-id="${onboardingEntryId}"]`) const onboardingEntryLink = unreadOnboardingEntry.locator("a[href]").first() await unreadOnboardingEntry.scrollIntoViewIfNeeded().catch(() => {}) await expect(onboardingEntryLink).toBeVisible({ timeout: 15_000 }) await onboardingEntryLink.click() const entryRender = page.getByTestId("entry-render") await expect(entryRender).toBeVisible({ timeout: 15_000 }) await expect(onboardingEntry).toHaveAttribute("data-active", "true", { timeout: 15_000 }) await expect(onboardingEntry).toHaveAttribute("data-read", "true", { timeout: 15_000 }) const toggleReadButton = page.getByTestId("command-action-entry-read").last() await expect(toggleReadButton).toBeVisible({ timeout: 15_000 }) await expect(toggleReadButton).toBeEnabled({ timeout: 15_000 }) await toggleReadButton.click() await expect(onboardingEntry).toHaveAttribute("data-read", "false", { timeout: 15_000 }) await toggleReadButton.click() await expect(onboardingEntry).toHaveAttribute("data-read", "true", { timeout: 15_000 }) } ================================================ FILE: apps/desktop/e2e/support/auth-bootstrap.ts ================================================ import type { BrowserContext, Page } from "@playwright/test" import { nanoid } from "nanoid" import type { TestAccount } from "./account" import { injectRecaptchaToken, waitForAuthenticated } from "./app" import type { DesktopE2EEnv } from "./env" import { buildWebAppURL } from "./env" type AuthBootstrapResponse = { token?: string | null error?: { message?: string } | null } type ParsedCookie = { expires?: number httpOnly: boolean name: string path: string sameSite: "Lax" | "None" | "Strict" secure: boolean value: string } const splitSetCookieHeader = (header: string) => { const parts: string[] = [] let buffer = "" for (const char of header) { if (char === ",") { const recent = buffer.toLowerCase() const hasExpires = recent.includes("expires=") const hasGmt = /gmt/i.test(recent) if (hasExpires && !hasGmt) { buffer += char continue } if (buffer.trim()) { parts.push(buffer.trim()) } buffer = "" continue } buffer += char } if (buffer.trim()) { parts.push(buffer.trim()) } return parts } const parseSetCookieHeader = (header: string): ParsedCookie[] => { return splitSetCookieHeader(header) .map((cookie) => { const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim()) const [name, ...valueParts] = nameValue?.split("=") ?? [] if (!name) { return null } const parsedCookie: ParsedCookie = { name, value: valueParts.join("="), path: "/", httpOnly: false, secure: false, sameSite: "Lax", } for (const attribute of attributes) { const [rawKey, ...rawValueParts] = attribute.split("=") const key = rawKey?.toLowerCase() const value = rawValueParts.join("=") switch (key) { case "expires": { const expires = new Date(value) if (!Number.isNaN(expires.getTime())) { parsedCookie.expires = expires.getTime() / 1000 } break } case "httponly": { parsedCookie.httpOnly = true break } case "path": { parsedCookie.path = value || "/" break } case "samesite": { if (value === "None" || value === "Strict" || value === "Lax") { parsedCookie.sameSite = value } break } case "secure": { parsedCookie.secure = true break } } } return parsedCookie }) .filter(Boolean) } const requestAuth = async ({ apiURL, path, body, }: { apiURL: string body: Record<string, unknown> path: string }) => { const response = await fetch(new URL(path, apiURL), { method: "POST", headers: { "Cache-Control": "no-store", "content-type": "application/json", "x-app-name": "Folo Web", "x-app-platform": "desktop/web", "x-app-version": "1.4.0", "x-client-id": nanoid(), "x-session-id": nanoid(), "x-token": "ac:fallback", }, body: JSON.stringify(body), }) return { response, body: (await response.json().catch(() => null)) as AuthBootstrapResponse | null, setCookie: response.headers.get("set-cookie"), } } const signIn = (env: DesktopE2EEnv, account: TestAccount) => requestAuth({ apiURL: env.apiURL, path: "/better-auth/sign-in/email", body: { email: account.email, password: account.password, rememberMe: true, }, }) const signUp = (env: DesktopE2EEnv, account: TestAccount) => requestAuth({ apiURL: env.apiURL, path: "/better-auth/sign-up/email", body: { email: account.email, password: account.password, name: account.email.split("@")[0] ?? account.email, callbackURL: `${env.webURL}/login`, }, }) const applyCookiesToContext = async ( context: BrowserContext, env: DesktopE2EEnv, setCookieHeader: string, ) => { const cookies = parseSetCookieHeader(setCookieHeader) await context.addCookies( cookies.map((cookie) => ({ url: env.apiURL, name: cookie.name, value: cookie.value, httpOnly: cookie.httpOnly, secure: cookie.secure, sameSite: cookie.sameSite, expires: cookie.expires, })), ) } export const bootstrapAuthenticatedWebSession = async ( page: Page, env: DesktopE2EEnv, account: TestAccount, ) => { let signInResult = await signIn(env, account) if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) { const signUpResult = await signUp(env, account) const signUpError = signUpResult.body?.error?.message?.toLowerCase() ?? "" const isExistingAccount = signUpError.includes("exist") || signUpError.includes("already") || signUpError.includes("taken") if ((!signUpResult.response.ok || signUpResult.body?.error) && !isExistingAccount) { throw new Error( signUpResult.body?.error?.message || signInResult.body?.error?.message || `auth bootstrap failed with ${signUpResult.response.status}`, ) } signInResult = await signIn(env, account) } if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) { throw new Error( signInResult.body?.error?.message || `sign in failed with ${signInResult.response.status}`, ) } await applyCookiesToContext(page.context(), env, signInResult.setCookie) await injectRecaptchaToken(page, env) await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) await waitForAuthenticated(page) } ================================================ FILE: apps/desktop/e2e/support/electron.ts ================================================ import { execSync } from "node:child_process" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import type { ElectronApplication, Page } from "@playwright/test" import { _electron as electron } from "@playwright/test" import { join } from "pathe" import type { DesktopE2EEnv } from "./env" let buildSignature: string | null = null const ensureElectronBuilt = (env: DesktopE2EEnv) => { const nextSignature = `${env.apiURL}|${env.webURL}` if (buildSignature === nextSignature) { return } execSync("pnpm run build:electron-vite", { cwd: env.desktopAppDir, env: { ...process.env, VITE_API_URL: env.apiURL, VITE_WEB_URL: env.webURL, }, stdio: "inherit", }) buildSignature = nextSignature } export const launchElectronApp = async (env: DesktopE2EEnv) => { ensureElectronBuilt(env) const userDataDir = await mkdtemp(join(tmpdir(), "folo-e2e-")) const electronApp = await electron.launch({ args: [env.desktopAppDir], cwd: env.desktopAppDir, env: { ...process.env, CI: process.env.CI ?? "1", NODE_ENV: "test", VITE_API_URL: env.apiURL, VITE_WEB_URL: env.webURL, FOLO_E2E_USER_DATA_DIR: userDataDir, }, timeout: 120_000, }) const page = await electronApp.firstWindow() await page.waitForLoadState("domcontentloaded") await page.evaluate(() => { window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token" const originalFetch = globalThis.fetch.bind(globalThis) const authEndpoints = [ "/better-auth/sign-in/email", "/better-auth/sign-up/email", "/better-auth/forget-password", ] globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init) const requestURL = new URL(request.url, globalThis.location.origin) const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path)) if (!shouldInjectToken) { return originalFetch(input, init) } const headers = new Headers(request.headers) if (!headers.has("x-token")) { headers.set("x-token", "r3:e2e-token") } return originalFetch(new Request(request, { headers })) } }) return { electronApp, page, userDataDir, } } export const closeElectronApp = async (app: { electronApp: ElectronApplication page: Page userDataDir: string }) => { await app.electronApp.close().catch(() => {}) await rm(app.userDataDir, { force: true, recursive: true }) } ================================================ FILE: apps/desktop/e2e/support/env.ts ================================================ import { fileURLToPath } from "node:url" import { join } from "pathe" export type DesktopE2EProfile = "local" | "prod" const DESKTOP_E2E_PROFILES = { local: { apiURL: "http://localhost:3000", webURL: "http://localhost:2233", webBaseURL: "http://localhost:2233", webUsesHashRouter: false, }, prod: { apiURL: "https://api.folo.is", webURL: "https://app.folo.is", webBaseURL: null, webUsesHashRouter: true, }, } as const export interface DesktopE2EEnv { profile: DesktopE2EProfile apiURL: string webURL: string webBaseURL: string webUsesHashRouter: boolean webDevServerURL: string debugProxyPath: string desktopAppDir: string } const supportDir = fileURLToPath(new URL(".", import.meta.url)) const desktopAppDir = join(supportDir, "..", "..") const normalizeRoute = (route: string) => { if (!route || route === "/") { return "/" } return route.startsWith("/") ? route : `/${route}` } export const resolveDesktopE2EEnv = (): DesktopE2EEnv => { const profile = (process.env.FOLO_E2E_PROFILE ?? "local") as DesktopE2EProfile const resolvedProfile = profile in DESKTOP_E2E_PROFILES ? profile : "local" const profileConfig = DESKTOP_E2E_PROFILES[resolvedProfile] const webDevServerURL = process.env.FOLO_E2E_WEB_DEV_SERVER_URL ?? "http://localhost:2233" const debugProxyPath = process.env.FOLO_E2E_WEB_DEBUG_PROXY_PATH ?? "/__debug_proxy.html" const webBaseURL = resolvedProfile === "prod" ? new URL( `${debugProxyPath}?debug-host=${encodeURIComponent(webDevServerURL)}`, profileConfig.webURL, ).toString() : profileConfig.webBaseURL return { profile: resolvedProfile, apiURL: process.env.FOLO_E2E_API_URL ?? profileConfig.apiURL, webURL: process.env.FOLO_E2E_WEB_URL ?? profileConfig.webURL, webBaseURL, webUsesHashRouter: profileConfig.webUsesHashRouter, webDevServerURL, debugProxyPath, desktopAppDir, } } export const buildWebAppURL = (env: DesktopE2EEnv, route = "/") => { const normalizedRoute = normalizeRoute(route) if (env.webUsesHashRouter) { const url = new URL(env.webBaseURL) url.hash = normalizedRoute return url.toString() } return new URL(normalizedRoute, `${env.webBaseURL}/`).toString() } export const buildHashRoute = (route = "/") => normalizeRoute(route) ================================================ FILE: apps/desktop/e2e/tests/electron/core.spec.ts ================================================ import { expect, test } from "@playwright/test" import { createTestAccount, tryDeleteCurrentUser } from "../../support/account" import { dismissFeedForm, expectTimelineSwitchAndEntryReadFlow, followOnboardingFeed, loginWithCredential, logoutFromProfileMenu, registerWithCredential, unsubscribeFirstFeedFromSettings, } from "../../support/app" import { closeElectronApp, launchElectronApp } from "../../support/electron" import { resolveDesktopE2EEnv } from "../../support/env" test.describe("electron core flows", () => { test("covers registration, login, follow, unfollow, timeline and read state", async () => { test.setTimeout(240_000) const env = resolveDesktopE2EEnv() const account = createTestAccount("electron-core") let electronApp = await launchElectronApp(env) try { await test.step("registers a new account", async () => { await registerWithCredential(electronApp.page, account) }) await test.step("logs out and logs back in", async () => { await logoutFromProfileMenu(electronApp.page) await closeElectronApp(electronApp) electronApp = await launchElectronApp(env) await loginWithCredential(electronApp.page, account) }) await test.step("follows onboarding feed", async () => { await followOnboardingFeed(electronApp.page, env) await dismissFeedForm(electronApp.page) }) await test.step("switches timeline, opens an entry, and toggles read state", async () => { await expectTimelineSwitchAndEntryReadFlow(electronApp.page) }) await test.step("unsubscribes onboarding feed from settings", async () => { await unsubscribeFirstFeedFromSettings(electronApp.page) }) const cleanup = await tryDeleteCurrentUser(electronApp.page, env) expect(cleanup.status).toBeGreaterThanOrEqual(-1) test.info().annotations.push({ type: "cleanup", description: `delete-user-custom status=${cleanup.status}`, }) } finally { await closeElectronApp(electronApp) } }) }) ================================================ FILE: apps/desktop/e2e/tests/web/core.spec.ts ================================================ import { expect, test } from "@playwright/test" import { createTestAccount, tryDeleteCurrentUser } from "../../support/account" import { closeSettings, dismissFeedForm, expectOnboardingFeedUnsubscribed, expectTimelineSwitchAndEntryReadFlow, followOnboardingFeed, loginWithCredential, logoutFromProfileMenu, openWebApp, registerWithCredential, unsubscribeFirstFeedFromSettings, } from "../../support/app" import { resolveDesktopE2EEnv } from "../../support/env" test.describe("web core flows", () => { test("covers registration, login, follow, unfollow, timeline and read state", async ({ page, browser, }) => { test.setTimeout(180_000) const env = resolveDesktopE2EEnv() const account = createTestAccount("web-core") let activePage = page let loginContext: Awaited<ReturnType<typeof browser.newContext>> | null = null try { await openWebApp(activePage, env) await test.step("registers a new account", async () => { await registerWithCredential(activePage, account) }) await test.step("follows onboarding feed", async () => { await followOnboardingFeed(activePage, env) await dismissFeedForm(activePage) }) await test.step("logs out and logs back in", async () => { await logoutFromProfileMenu(activePage) loginContext = await browser.newContext() activePage = await loginContext.newPage() await openWebApp(activePage, env) await loginWithCredential(activePage, account) }) await test.step("switches timeline, opens an entry, and toggles read state", async () => { await expectTimelineSwitchAndEntryReadFlow(activePage) }) await test.step("unsubscribes onboarding feed from settings", async () => { await unsubscribeFirstFeedFromSettings(activePage) await closeSettings(activePage) await expectOnboardingFeedUnsubscribed(activePage, env) }) await test.step("re-subscribes onboarding feed", async () => { await followOnboardingFeed(activePage, env) await dismissFeedForm(activePage) }) await test.step("tries to clean up the temporary account", async () => { const cleanup = await tryDeleteCurrentUser(activePage, env) expect(cleanup.status).toBeGreaterThanOrEqual(-1) test.info().annotations.push({ type: "cleanup", description: `delete-user-custom status=${cleanup.status}`, }) }) } finally { await loginContext?.close().catch(() => {}) } }) }) ================================================ FILE: apps/desktop/e2e/tests/web/settings-sync.spec.ts ================================================ import type { BrowserContext } from "@playwright/test" import { expect, test } from "@playwright/test" import { createTestAccount, tryDeleteCurrentUser } from "../../support/account" import { getLanguageLabel, loginWithCredential, openSettings, openWebApp, registerWithCredential, setLanguage, } from "../../support/app" import { resolveDesktopE2EEnv } from "../../support/env" const closeContextSafely = async (context: BrowserContext) => { try { await context.close() } catch (error) { if (error instanceof Error && error.message.includes("ENOENT")) { return } throw error } } test.describe("web multi-session sync", () => { test("syncs settings between two browser sessions", async ({ browser }) => { test.setTimeout(180_000) const env = resolveDesktopE2EEnv() const account = createTestAccount("web-sync") const contextA = await browser.newContext() const contextB = await browser.newContext() const pageA = await contextA.newPage() const pageB = await contextB.newPage() try { await openWebApp(pageA, env) await registerWithCredential(pageA, account) await openWebApp(pageB, env) await loginWithCredential(pageB, account) await openSettings(pageA) await openSettings(pageB) await test.step("session A change syncs to session B", async () => { await setLanguage(pageA, "日本語") await expect .poll(async () => getLanguageLabel(pageA), { timeout: 15_000 }) .toContain("日本語") await expect .poll( async () => { await pageB.reload({ waitUntil: "domcontentloaded" }) await openSettings(pageB) return getLanguageLabel(pageB) }, { timeout: 30_000 }, ) .toContain("日本語") }) await test.step("session B change syncs back to session A", async () => { await setLanguage(pageB, "English") await expect .poll(async () => getLanguageLabel(pageB), { timeout: 15_000 }) .toContain("English") await expect .poll( async () => { await pageA.reload({ waitUntil: "domcontentloaded" }) await openSettings(pageA) return getLanguageLabel(pageA) }, { timeout: 60_000 }, ) .toContain("English") }) const cleanup = await tryDeleteCurrentUser(pageA, env) expect(cleanup.status).toBeGreaterThanOrEqual(-1) test.info().annotations.push({ type: "cleanup", description: `delete-user-custom status=${cleanup.status}`, }) } finally { await closeContextSafely(contextA) await closeContextSafely(contextB) } }) }) ================================================ FILE: apps/desktop/electron.vite.config.ts ================================================ import { defineConfig } from "electron-vite" import { resolve } from "pathe" import { getGitHash } from "../../scripts/lib" import rendererConfig from "./configs/vite.electron-render.config" export default defineConfig({ main: { build: { outDir: "dist/main", lib: { entry: "./layer/main/src/index.ts", }, }, resolve: { alias: { "@shared": resolve("packages/shared/src"), "@pkg": resolve("./package.json"), "@locales": resolve("../../locales"), "~": resolve("./layer/main/src"), "utf-8-validate": resolve("./layer/main/src/shims/utf-8-validate.cjs"), }, }, define: { ELECTRON: "true", GIT_COMMIT_HASH: JSON.stringify(getGitHash()), }, }, preload: { build: { outDir: "dist/preload", lib: { entry: "./layer/main/preload/index.ts", }, }, resolve: { alias: { "@pkg": resolve("./package.json"), "@locales": resolve("../../locales"), }, }, }, renderer: rendererConfig, }) ================================================ FILE: apps/desktop/forge.config.cts ================================================ import crypto from "node:crypto" import fs, { readdirSync } from "node:fs" import { cp, readdir } from "node:fs/promises" import { FuseV1Options, FuseVersion } from "@electron/fuses" import { MakerAppX } from "@electron-forge/maker-appx" import { MakerDMG } from "@electron-forge/maker-dmg" import { MakerPKG } from "@electron-forge/maker-pkg" import { MakerSquirrel } from "@electron-forge/maker-squirrel" import { MakerZIP } from "@electron-forge/maker-zip" import { FusesPlugin } from "@electron-forge/plugin-fuses" import type { ForgeConfig } from "@electron-forge/shared-types" import MakerAppImage from "@pengx17/electron-forge-maker-appimage" import setLanguages from "electron-packager-languages" import yaml from "js-yaml" import path, { resolve } from "pathe" import { rimraf, rimrafSync } from "rimraf" const ResolvedMakerAppImage: typeof MakerAppImage = (MakerAppImage as any).default || MakerAppImage const platform = process.argv.find((arg) => arg.startsWith("--platform"))?.split("=")[1] const mode = process.argv.find((arg) => arg.startsWith("--mode"))?.split("=")[1] const isMicrosoftStore = process.argv.find((arg) => arg.startsWith("--ms"))?.split("=")[1] === "true" const isStaging = mode === "staging" const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip)$/ const platformNamesMap = { darwin: "macos", linux: "linux", win32: "windows", } const ymlMapsMap = { darwin: "latest-mac.yml", linux: "latest-linux.yml", win32: "latest.yml", } const keepModules = new Set(["font-list", "vscode-languagedetection"]) const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"]) // remove folders & files not to be included in the app async function cleanSources(buildPath, _electronVersion, platform, _arch, callback) { // folders & files to be included in the app const appItems = new Set(["dist", "node_modules", "package.json", "resources"]) if (platform === "darwin" || platform === "mas") { const frameworkResourcePath = resolve( buildPath, "../../Frameworks/Electron Framework.framework/Versions/A/Resources", ) for (const file of readdirSync(frameworkResourcePath)) { if (file.endsWith(".lproj") && !keepLanguages.has(file.split(".")[0]!)) { rimrafSync(resolve(frameworkResourcePath, file)) } } } // Keep only node_modules to be included in the app await Promise.all([ ...(await readdir(buildPath).then((items) => items.filter((item) => !appItems.has(item)).map((item) => rimraf(path.join(buildPath, item))), )), ...(await readdir(path.join(buildPath, "node_modules")).then((items) => items .filter((item) => !keepModules.has(item)) .map((item) => rimraf(path.join(buildPath, "node_modules", item))), )), ]) // copy needed node_modules to be included in the app await Promise.all( Array.from(keepModules.values()).map((item) => { // Check is exist if (fs.existsSync(path.join(buildPath, "node_modules", item))) { // eslint-disable-next-line array-callback-return return } return cp( path.join(process.cwd(), "../../node_modules", item), path.join(buildPath, "node_modules", item), { recursive: true, }, ) }), ) callback() } const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback() const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`) const config: ForgeConfig = { packagerConfig: { name: isStaging ? "Folo Staging" : "Folo", appCategoryType: "public.app-category.news", buildVersion: process.env.BUILD_VERSION || undefined, appBundleId: "is.follow", icon: isStaging ? "resources/icon-staging" : "resources/icon", extraResource: ["./resources/app-update.yml"], protocols: [ { name: "Folo", schemes: ["follow"], }, { name: "Folo", schemes: ["folo"], }, ], afterCopy: [ cleanSources, process.platform !== "win32" ? noopAfterCopy : setLanguages([...keepLanguages.values()]), ], asar: true, ignore: [ignorePattern], prune: false, extendInfo: { ITSAppUsesNonExemptEncryption: false, }, osxSign: { optionsForFile: platform === "mas" ? (filePath) => { const entitlements = filePath.includes(".app/") ? "build/entitlements.mas.child.plist" : "build/entitlements.mas.plist" return { hardenedRuntime: false, entitlements, } } : () => ({ entitlements: "build/entitlements.mac.plist", }), keychain: process.env.OSX_SIGN_KEYCHAIN_PATH, identity: process.env.OSX_SIGN_IDENTITY, provisioningProfile: process.env.OSX_SIGN_PROVISIONING_PROFILE_PATH, }, ...(process.env.APPLE_ID && process.env.APPLE_PASSWORD && process.env.APPLE_TEAM_ID && { osxNotarize: { appleId: process.env.APPLE_ID!, appleIdPassword: process.env.APPLE_PASSWORD!, teamId: process.env.APPLE_TEAM_ID!, }, }), }, rebuildConfig: {}, makers: [ new MakerZIP({}, ["darwin"]), new MakerDMG( { overwrite: true, background: "static/dmg-background.png", icon: "static/dmg-icon.icns", iconSize: 160, additionalDMGOptions: { window: { size: { width: 660, height: 400, }, }, }, contents: (opts) => [ { x: 180, y: 170, type: "file", path: (opts as any).appPath, }, { x: 480, y: 170, type: "link", path: "/Applications", }, ], }, ["darwin", "mas"], ), new ResolvedMakerAppImage({ config: { icons: [ { file: isStaging ? "resources/icon-staging.png" : "resources/icon.png", size: 256, }, ], }, }), new MakerPKG( { name: "Folo", keychain: process.env.KEYCHAIN_PATH, }, ["mas"], ), // Only include AppX maker for Microsoft Store builds ...(isMicrosoftStore ? [ new MakerAppX({ publisher: "CN=7CBBEB6A-9B0E-4387-BAE3-576D0ACA279E", packageDisplayName: "Folo - Follow everything in one place", devCert: "build/dev.pfx", assets: "static/appx", manifest: "build/appxmanifest.xml", // @ts-ignore publisherDisplayName: "Natural Selection Labs", identityName: "NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo", packageBackgroundColor: "#FF5C00", protocol: "folo", }), ] : [ new MakerSquirrel({ name: "Folo", setupIcon: isStaging ? "resources/icon-staging.ico" : "resources/icon.ico", iconUrl: "https://app.folo.is/favicon.ico", }), ]), ], plugins: [ // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application new FusesPlugin({ version: FuseVersion.V1, [FuseV1Options.RunAsNode]: false, [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, [FuseV1Options.EnableNodeCliInspectArguments]: false, [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, [FuseV1Options.OnlyLoadAppFromAsar]: true, }), ], publishers: [ { name: "@electron-forge/publisher-github", config: { repository: { owner: "RSSNext", name: "follow", }, draft: true, }, }, ], hooks: { postMake: async (_config, makeResults) => { const yml: { version?: string files: { url: string sha512: string size: number }[] releaseDate?: string } = { version: makeResults[0]?.packageJSON?.version, files: [], } let basePath = "" makeResults = makeResults.map((result) => { result.artifacts = result.artifacts .map((artifact) => { if (artifactRegex.test(artifact)) { if (!basePath) { basePath = path.dirname(artifact) } const newArtifact = `${path.dirname(artifact)}/${ result.packageJSON.productName }-${result.packageJSON.version}-${ platformNamesMap[result.platform] }-${result.arch}${path.extname(artifact)}` fs.renameSync(artifact, newArtifact) try { const fileData = fs.readFileSync(newArtifact) const hash = crypto.createHash("sha512").update(fileData).digest("base64") const { size } = fs.statSync(newArtifact) yml.files.push({ url: path.basename(newArtifact), sha512: hash, size, }) } catch { console.error(`Failed to hash ${newArtifact}`) } return newArtifact } else if (!artifact.endsWith(".tmp")) { return artifact } else { return null } }) .filter((artifact) => artifact !== null) return result }) yml.releaseDate = new Date().toISOString() if (makeResults[0]?.platform && ymlMapsMap[makeResults[0].platform] && basePath) { const ymlPath = path.join(basePath, ymlMapsMap[makeResults[0].platform]) const ymlStr = yaml.dump(yml, { lineWidth: -1, }) fs.writeFileSync(ymlPath, ymlStr) makeResults.push({ artifacts: [ymlPath], platform: makeResults[0]!.platform, arch: makeResults[0]!.arch, packageJSON: makeResults[0]!.packageJSON, }) } return makeResults }, }, } export default config ================================================ FILE: apps/desktop/layer/main/export.ts ================================================ // Export types for renderer to use export type { IpcServices } from "./src/ipc" ================================================ FILE: apps/desktop/layer/main/global.d.ts ================================================ import "../../types/vite" declare global { const GIT_COMMIT_HASH: string } export {} ================================================ FILE: apps/desktop/layer/main/package.json ================================================ { "name": "@follow/electron-main", "type": "module", "private": true, "author": "Folo Team", "license": "GPL-3.0-only", "homepage": "https://github.com/RSSNext", "repository": { "url": "https://github.com/RSSNext/follow", "type": "git" }, "exports": { ".": { "types": "./dist/export.d.ts", "import": "./dist/export.js" } }, "types": "./dist/export.d.ts", "scripts": { "build": "tsc", "test": "vitest", "typecheck": "tsc --noEmit" }, "dependencies": { "@electron-toolkit/preload": "3.0.2", "@electron-toolkit/utils": "4.0.0", "@eneris/push-receiver": "4.3.0", "@follow-app/client-sdk": "catalog:", "@follow-app/readability": "workspace:*", "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", "builder-util-runtime": "9.5.1", "electron-context-menu": "4.1.1", "electron-ipc-decorator": "0.2.0", "electron-log": "5.4.3", "electron-squirrel-startup": "1.0.1", "electron-store": "11.0.2", "electron-updater": "6.7.3", "es-toolkit": "1.44.0", "font-list": "2.0.2", "i18next": "25.8.6", "js-yaml": "4.1.1", "ky": "1.14.3", "linkedom": "0.18.12", "lowdb": "7.0.1", "msedge-tts": "2.0.4", "node-machine-id": "1.1.12", "ofetch": "1.5.1", "pathe": "2.0.3", "semver": "7.7.4", "tar": "7.5.7", "vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.22" }, "devDependencies": { "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "25.2.3", "electron": "38.3.0", "electron-devtools-installer": "4.0.0", "typescript": "catalog:" } } ================================================ FILE: apps/desktop/layer/main/preload/index.d.ts ================================================ import type { ElectronAPI } from "@electron-toolkit/preload" declare global { interface Window { electron?: ElectronAPI api?: { canWindowBlur: boolean } platform: NodeJS.Platform } } ================================================ FILE: apps/desktop/layer/main/preload/index.ts ================================================ import os from "node:os" import { platform } from "node:process" import { electronAPI } from "@electron-toolkit/preload" import { clipboard, contextBridge } from "electron" export const isMacOS = platform === "darwin" export const isWindows = platform === "win32" export const isLinux = platform === "linux" /** * @see https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information * Windows 11 buildNumber starts from 22000. */ const detectingWindows11 = () => { if (!isWindows) return false const release = os.release() const majorVersion = Number.parseInt(release.split(".")[0]!) const buildNumber = Number.parseInt(release.split(".")[2]!) return majorVersion === 10 && buildNumber >= 22000 } export const isWindows11 = detectingWindows11() // Custom APIs for renderer const api = { canWindowBlur: process.platform === "darwin" || (process.platform === "win32" && isWindows11), isWindowsStore: Boolean((process as typeof process & { windowsStore?: boolean }).windowsStore), } // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise // just add to the DOM global. if (process.contextIsolated) { try { contextBridge.exposeInMainWorld("electron", electronAPI) contextBridge.exposeInMainWorld("api", api) contextBridge.exposeInMainWorld("platform", process.platform) } catch (error) { console.error(error) } } else { // @ts-ignore (define in dts) window.electron = electronAPI // @ts-ignore (define in dts) window.api = api // @ts-ignore (define in dts) window.platform = process.platform Object.defineProperty(window.navigator, "clipboard", { get: () => { return clipboard }, }) } ================================================ FILE: apps/desktop/layer/main/src/@types/constants.ts ================================================ const langs = ["en", "zh-CN", "zh-TW", "ja"] as const export const currentSupportedLanguages = [...langs].sort() as string[] export type MainSupportedLanguages = (typeof langs)[number] export const ns = ["native"] as const export const defaultNS = "native" as const ================================================ FILE: apps/desktop/layer/main/src/@types/i18next.d.ts ================================================ import type { defaultNS, ns } from "./constants" import type { resources } from "./resources" declare module "i18next" { interface CustomTypeOptions { ns: typeof ns resources: (typeof resources)["en"] defaultNS: typeof defaultNS // if you see an error like: "Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz" // set returnNull to false (and also in the i18next init options) // returnNull: false; } } ================================================ FILE: apps/desktop/layer/main/src/@types/resources.ts ================================================ import en from "@locales/native/en.json" import ja from "@locales/native/ja.json" import zhCn from "@locales/native/zh-CN.json" import zhTw from "@locales/native/zh-TW.json" import type { MainSupportedLanguages, ns } from "./constants" export const resources = { en: { native: en, }, "zh-CN": { native: zhCn, }, "zh-TW": { native: zhTw, }, ja: { native: ja, }, } satisfies Record<MainSupportedLanguages, Record<(typeof ns)[number], Record<string, string>>> ================================================ FILE: apps/desktop/layer/main/src/before-bootstrap.ts ================================================ import { app, protocol } from "electron" import path from "pathe" const e2eUserDataDir = process.env.FOLO_E2E_USER_DATA_DIR if (e2eUserDataDir) { app.setPath("userData", e2eUserDataDir) } else if (import.meta.env.DEV) { app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)")) } protocol.registerSchemesAsPrivileged([ { scheme: "app", privileges: { standard: true, bypassCSP: true, supportFetchAPI: true, secure: true, }, }, ]) ================================================ FILE: apps/desktop/layer/main/src/bootstrap.ts ================================================ import { app } from "electron" import squirrelStartup from "electron-squirrel-startup" import { DEVICE_ID } from "./constants/system" import { BootstrapManager } from "./manager/bootstrap" console.info("[main] device id:", DEVICE_ID) if (squirrelStartup) { app.quit() } BootstrapManager.start() ================================================ FILE: apps/desktop/layer/main/src/constants/app.ts ================================================ import { app } from "electron" import path from "pathe" export const UNREAD_BACKGROUND_POLLING_INTERVAL = 1000 * 60 * 5 export const HOTUPDATE_RENDER_ENTRY_DIR = path.resolve(app.getPath("userData"), "render") export const GITHUB_OWNER = process.env.GITHUB_OWNER || "RSSNext" export const GITHUB_REPO = process.env.GITHUB_REPO || "follow" // https://github.com/electron/electron/issues/25081 export const START_IN_TRAY_ARGS = "--start-in-tray" export const BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN = "better-auth.session_token" ================================================ FILE: apps/desktop/layer/main/src/constants/system.ts ================================================ import { machineIdSync } from "node-machine-id" export const DEVICE_ID = machineIdSync() ================================================ FILE: apps/desktop/layer/main/src/env.ts ================================================ import os from "node:os" import { DEV } from "@follow/shared/constants" export const channel: "development" | "beta" | "alpha" | "stable" = DEV ? "development" : "stable" const { platform } = process export const isMacOS = platform === "darwin" export const isMAS = process.mas export const isWindows = platform === "win32" export const isLinux = platform === "linux" /** * @see https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information * Windows 11 buildNumber starts from 22000. */ const detectingWindows11 = () => { if (!isWindows) return false const release = os.release() const majorVersion = Number.parseInt(release.split(".")[0]!) const buildNumber = Number.parseInt(release.split(".")[2]!) return majorVersion === 10 && buildNumber >= 22000 } export const isWindows11 = detectingWindows11() ================================================ FILE: apps/desktop/layer/main/src/helper.ts ================================================ import { fileURLToPath, pathToFileURL } from "node:url" import { MODE, ModeEnum } from "@follow/shared/constants" import path from "pathe" import { isMacOS, isWindows } from "./env" const __dirname = fileURLToPath(new URL(".", import.meta.url)) const iconMap = { [ModeEnum.production]: path.join(__dirname, "../../resources/icon.png"), [ModeEnum.development]: path.join(__dirname, "../../resources/icon-dev.png"), [ModeEnum.staging]: path.join(__dirname, "../../resources/icon-staging.png"), } export const getIconPath = () => iconMap[MODE] export const getTrayIconPath = () => { if (isMacOS) { return path.join(__dirname, "../../resources/icon-tray.png") } if (isWindows) { // https://www.electronjs.org/docs/latest/api/tray#:~:text=Windows,best%20visual%20effects. return MODE === ModeEnum.staging ? path.join(__dirname, "../../resources/icon-tray-staging.ico") : path.join(__dirname, "../../resources/icon-tray.ico") } return getIconPath() } export const filePathToAppUrl = (filePath: string) => { return `app://folo.is${pathToFileURL(filePath).pathname}` } ================================================ FILE: apps/desktop/layer/main/src/index.ts ================================================ import "./before-bootstrap" import "./bootstrap" ================================================ FILE: apps/desktop/layer/main/src/ipc/index.ts ================================================ import type { MergeIpcService } from "electron-ipc-decorator" import { createServices } from "electron-ipc-decorator" import { AppService } from "./services/app" import { AuthService } from "./services/auth" import { CliService } from "./services/cli" import { DebugService } from "./services/debug" import { DockService } from "./services/dock" import { IntegrationService } from "./services/integration" import { MenuService } from "./services/menu" import { ReaderService } from "./services/reader" import { SettingService } from "./services/setting" // Initialize all services const services = createServices([ AppService, AuthService, CliService, DebugService, DockService, MenuService, ReaderService, SettingService, IntegrationService, ]) // Extract method types automatically from services export type IpcServices = MergeIpcService<typeof services> // Initialize all services (this will register all IPC handlers) export function initializeIpcServices() { // Services are already initialized in the services constant above console.info("IPC services initialized") void services } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/app.ts ================================================ import fsp from "node:fs/promises" import { fileURLToPath } from "node:url" import { callWindowExpose } from "@follow/shared/bridge" import { DEV } from "@follow/shared/constants" import { app, BrowserWindow, clipboard, dialog, shell } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import path from "pathe" import { START_IN_TRAY_ARGS } from "~/constants/app" import { getCacheSize } from "~/lib/cleaner" import { i18n } from "~/lib/i18n" import { store, StoreKey } from "~/lib/store" import { registerAppTray } from "~/lib/tray" import { logger, revealLogFile } from "~/logger" import { AppManager } from "~/manager/app" import { WindowManager } from "~/manager/window" import { cleanupOldRender, loadDynamicRenderEntry } from "~/updater/hot-updater" import { downloadFile } from "../../lib/download" import { checkForAppUpdates, quitAndInstall } from "../../updater" interface WindowActionInput { action: "close" | "minimize" | "maximum" } interface SearchInput { text: string options: Electron.FindInPageOptions } interface Sender extends Electron.WebContents { getOwnerBrowserWindow: () => Electron.BrowserWindow | null } export class AppService extends IpcService { static override readonly groupName = "app" @IpcMethod() getAppVersion(): string { return app.getVersion() } @IpcMethod() async checkForUpdates(): Promise<{ hasUpdate: boolean; error?: string }> { return checkForAppUpdates() } @IpcMethod() switchAppLocale(context: IpcContext, input: string): void { i18n.changeLanguage(input) AppManager.registerMenuAndContextMenu() registerAppTray() app.commandLine.appendSwitch("lang", input) } @IpcMethod() rendererUpdateReload(): void { const __dirname = fileURLToPath(new URL(".", import.meta.url)) const allWindows = BrowserWindow.getAllWindows() const dynamicRenderEntry = loadDynamicRenderEntry() const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html") logger.info("appLoadEntry", appLoadEntry) const mainWindow = WindowManager.getMainWindow() for (const window of allWindows) { if (window === mainWindow) { if (DEV) { logger.verbose("[rendererUpdateReload]: skip reload in dev") break } window.loadFile(appLoadEntry) } else window.destroy() } setTimeout(() => { cleanupOldRender() }, 1000) } @IpcMethod() async openExternal(_context: IpcContext, url: string): Promise<void> { if (!url) return await shell.openExternal(url) } @IpcMethod() windowAction(context: IpcContext, input: WindowActionInput): void { if (context.sender.getType() === "window") { const window: BrowserWindow | null = (context.sender as Sender).getOwnerBrowserWindow() if (!window) return switch (input.action) { case "close": { window.close() break } case "minimize": { window.minimize() break } case "maximum": { if (window.isMaximized()) { window.unmaximize() } else { window.maximize() } break } } } } @IpcMethod() quitAndInstall(_context: IpcContext): void { quitAndInstall() } @IpcMethod() readClipboard(_context: IpcContext): string { return clipboard.readText() } @IpcMethod() async search(context: IpcContext, input: SearchInput): Promise<Electron.Result | null> { const { sender: webContents } = context const { promise, resolve } = Promise.withResolvers<Electron.Result | null>() let requestId = -1 webContents.once("found-in-page", (_, result) => { resolve(result.requestId === requestId ? result : null) }) requestId = webContents.findInPage(input.text, input.options) return promise } @IpcMethod() clearSearch(context: IpcContext): void { context.sender.stopFindInPage("keepSelection") } @IpcMethod() async download(context: IpcContext, input: string): Promise<void> { const result = await dialog.showSaveDialog({ defaultPath: input.split("/").pop(), }) if (result.canceled) return try { await downloadFile(input, result.filePath) const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() if (senderWindow) { callWindowExpose(senderWindow).toast.success("Download success!", { duration: 1000, }) } } catch (err) { const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() if (senderWindow) { callWindowExpose(senderWindow).toast.error("Download failed!", { duration: 1000, }) } throw err } } @IpcMethod() getAppPath(_context: IpcContext): string { return app.getAppPath() } @IpcMethod() resolveAppAsarPath(context: IpcContext, input: string): string { if (input.startsWith("file://")) { input = fileURLToPath(input) } if (path.isAbsolute(input)) { return input } return path.join(app.getAppPath(), input) } @IpcMethod() readyToShowMainWindow(_context: IpcContext) { const shouldShowWindow = !app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS) if (shouldShowWindow) { const window = WindowManager.getMainWindow() if (window) window.show() } } @IpcMethod() openCacheFolder(_context: IpcContext): void { const dir = path.join(app.getPath("userData"), "cache") shell.openPath(dir) } @IpcMethod() getCacheLimit(_context: IpcContext): number { return store.get(StoreKey.CacheSizeLimit) || 0 } @IpcMethod() async clearCache(_context: IpcContext): Promise<void> { const cachePath = path.join(app.getPath("userData"), "cache", "Cache_Data") if (process.platform === "win32") { // Request elevation on Windows try { // Create a bat file to delete cache with elevated privileges const batPath = path.join(app.getPath("temp"), "clear_cache.bat") await fsp.writeFile(batPath, `@echo off\nrd /s /q "${cachePath}"\ndel "%~f0"`, "utf-8") // Execute the bat file with admin privileges await shell.openPath(batPath) return } catch (err) { logger.error("Failed to clear cache with elevation", { error: err }) } } await fsp.rm(cachePath, { recursive: true, force: true }).catch(() => { logger.error("Failed to clear cache") }) } @IpcMethod() limitCacheSize(_context: IpcContext, input: number): void { if (input === 0) { store.delete(StoreKey.CacheSizeLimit) } else { store.set(StoreKey.CacheSizeLimit, input) } } @IpcMethod() revealLogFile(_context: IpcContext) { return revealLogFile() } @IpcMethod() getCacheSize(_context: IpcContext) { return getCacheSize() } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/auth.ts ================================================ import { env } from "@follow/shared/env.desktop" import { createDesktopAPIHeaders } from "@follow/utils/headers" import PKG from "@pkg" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { WindowManager } from "~/manager/window" import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" import { logger } from "../../logger" export class AuthService extends IpcService { static override readonly groupName = "auth" private async applySessionToken(token: string): Promise<void> { const mainWindow = WindowManager.getMainWindow() if (!mainWindow || !token) { return } const apiURL = env.VITE_API_URL const url = new URL(apiURL) const isSecure = url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1" const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" const cookieNames = [ BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, ...(isSecure && !isLocalhost ? ["__Secure-better-auth.session_token"] : []), ] await Promise.all( cookieNames.map((name) => mainWindow.webContents.session.cookies.set({ url: apiURL, name, value: token, ...(isLocalhost ? {} : { domain: url.hostname }), path: "/", httpOnly: true, secure: isSecure, sameSite: "no_restriction", expirationDate: new Date().setDate(new Date().getDate() + 30), }), ), ) } private async clearSessionToken(): Promise<void> { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) { return } const { session } = mainWindow.webContents const apiURL = env.VITE_API_URL await Promise.allSettled([ session.cookies.remove(apiURL, BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN), session.cookies.remove(apiURL, "__Secure-better-auth.session_token"), session.cookies.remove(apiURL, "better-auth.last_used_login_method"), ]) } private async requestCredentialAuth( path: "/sign-in/email" | "/sign-up/email", payload: Record<string, unknown>, headers?: Record<string, string>, ) { const response = await fetch(`${env.VITE_API_URL}/better-auth${path}`, { method: "POST", headers: { "content-type": "application/json", ...createDesktopAPIHeaders({ version: PKG.version }), ...headers, }, body: JSON.stringify(payload), }) const data = (await response .json() .catch(async () => ({ message: await response.text() }))) as Record<string, unknown> const setCookie = response.headers.get("set-cookie") || "" const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/) const sessionToken = sessionCookieMatch?.[1] ?? null const token = typeof data.token === "string" ? data.token : null const persistedSessionToken = sessionToken ?? token if (response.ok && persistedSessionToken) { await this.applySessionToken(persistedSessionToken) } if (sessionToken) { data.sessionToken = sessionToken } return { data, error: response.ok ? null : { message: typeof data.message === "string" ? data.message : response.statusText, status: response.status, }, } } @IpcMethod() async sessionChanged(_context: IpcContext): Promise<void> { await updateNotificationsToken() // Sync the current desktop session to the npm CLI login. const token = await getSessionTokenFromCookies() await syncSessionToCliConfig(token).catch((err) => { logger.error("Failed to sync session to CLI config:", err) }) } @IpcMethod() async signOut(_context: IpcContext): Promise<void> { await deleteNotificationsToken() // Clear the synced CLI login on sign out. await syncSessionToCliConfig().catch((err) => { logger.error("Failed to clear CLI config token:", err) }) } @IpcMethod() async signOutRemote(_context: IpcContext, token?: string): Promise<void> { await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, { method: "POST", headers: { ...createDesktopAPIHeaders({ version: PKG.version }), ...(token ? { Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, } : {}), }, }).catch(() => {}) await this.clearSessionToken() } @IpcMethod() async signInWithCredential( _context: IpcContext, payload: { email: string; password: string; headers?: Record<string, string> }, ) { return this.requestCredentialAuth( "/sign-in/email", { email: payload.email, password: payload.password, }, payload.headers, ) } @IpcMethod() async signUpWithCredential( _context: IpcContext, payload: { email: string password: string name: string callbackURL: string headers?: Record<string, string> }, ) { return this.requestCredentialAuth( "/sign-up/email", { email: payload.email, password: payload.password, name: payload.name, callbackURL: payload.callbackURL, }, payload.headers, ) } @IpcMethod() async setSessionToken(_context: IpcContext, token: string): Promise<void> { await this.applySessionToken(token) } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/cli.ts ================================================ import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { CLI_NPM_PACKAGE_NAME, getCliConfigPath, getCliInstallCommand, getCliLoginCommand, getSessionTokenFromCookies, isCliRunnerAvailable, readCliConfig, syncSessionToCliConfig, } from "../../lib/cli-session-sync" export interface CliInstallStatus { connected: boolean configPath: string hasDesktopSession: boolean installCommand: string loginCommand: string npxAvailable: boolean packageName: string } export class CliService extends IpcService { static override readonly groupName = "cli" @IpcMethod() async getInstallStatus(_context: IpcContext): Promise<CliInstallStatus> { const [config, npxAvailable, desktopToken] = await Promise.all([ readCliConfig(), isCliRunnerAvailable(), getSessionTokenFromCookies(), ]) return { connected: Boolean(config.token), configPath: getCliConfigPath(), hasDesktopSession: Boolean(desktopToken), installCommand: getCliInstallCommand(), loginCommand: getCliLoginCommand(), npxAvailable, packageName: CLI_NPM_PACKAGE_NAME, } } @IpcMethod() async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { try { if (!(await isCliRunnerAvailable())) { return { success: false, error: "npx is not available. Install Node.js and npm first." } } const token = await getSessionTokenFromCookies() if (!token) { return { success: false, error: "Sign in to Folo Desktop first." } } await syncSessionToCliConfig(token) return { success: true } } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Failed to sync CLI login", } } } @IpcMethod() async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { try { await syncSessionToCliConfig() return { success: true } } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Failed to clear CLI login", } } } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/debug.ts ================================================ import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" interface InspectElementInput { x: number y: number } export class DebugService extends IpcService { static override readonly groupName = "debug" @IpcMethod() inspectElement(context: IpcContext, input: InspectElementInput): void { context.sender.inspectElement(input.x, input.y) } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/dock.ts ================================================ import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../../constants/app" import { apiClient } from "../../lib/api-client" import { setDockCount } from "../../lib/dock" class PollingManager { private abortController: AbortController | null = null private isPolling = false async startPolling(pollingFn: () => Promise<void>, interval: number): Promise<void> { if (this.isPolling) { return // Already polling, prevent duplicate instances } this.isPolling = true this.abortController = new AbortController() try { while (!this.abortController.signal.aborted) { await pollingFn() // Use AbortSignal with sleep for proper cancellation await this.sleepWithAbortSignal(interval, this.abortController.signal) } } catch (error) { if (error instanceof Error && error.name !== "AbortError") { console.error("Polling error:", error) } } finally { this.isPolling = false this.abortController = null } } stopPolling(): void { if (this.abortController) { this.abortController.abort() } } get active(): boolean { return this.isPolling } private async sleepWithAbortSignal(ms: number, signal: AbortSignal): Promise<void> { return new Promise((resolve, reject) => { const timeoutId = setTimeout(resolve, ms) signal.addEventListener("abort", () => { clearTimeout(timeoutId) reject(new DOMException("Aborted", "AbortError")) }) }) } } export class DockService extends IpcService { private unreadPollingManager = new PollingManager() static override readonly groupName = "dock" @IpcMethod() async pollingUpdateUnreadCount(): Promise<void> { await this.unreadPollingManager.startPolling( () => this.updateUnreadCount(), UNREAD_BACKGROUND_POLLING_INTERVAL, ) } @IpcMethod() async cancelPollingUpdateUnreadCount(): Promise<void> { this.unreadPollingManager.stopPolling() } @IpcMethod() async updateUnreadCount(): Promise<void> { const res = await apiClient.reads.getTotalCount() setDockCount(res.data.count) } @IpcMethod() setDockBadge(_context: IpcContext, count: number): void { setDockCount(count) } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/integration.ts ================================================ import { existsSync } from "node:fs" import fsp from "node:fs/promises" import { shell } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import path from "pathe" import { store } from "~/lib/store" import { logger } from "~/logger" // Taken from https://github.com/rollup/rollup/blob/4f69d33af3b2ec9320c43c9e6c65ea23a02bdde3/src/utils/sanitizeFileName.ts // https://datatracker.ietf.org/doc/html/rfc2396 // eslint-disable-next-line no-control-regex const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g const DRIVE_LETTER_REGEX = /^[a-z]:/i function sanitizeFileName(name: string): string { const match = DRIVE_LETTER_REGEX.exec(name) const driveLetter = match ? match[0] : "" // A `:` is only allowed as part of a windows drive letter (ex: C:\foo) // Otherwise, avoid them because they can refer to NTFS alternate data streams. return driveLetter + name.slice(driveLetter.length).replaceAll(INVALID_CHAR_REGEX, "_") } // Input types interface SaveToEagleInput { url: string mediaUrls: string[] } interface LoginToQBittorrentInput { host: string username: string password: string } interface CheckQBittorrentAuthInput { host: string } interface AddMagnetInput { host: string urls: string[] } interface CustomFetchInput { url: string method: string headers: Record<string, string> body?: string timeout?: number } export class IntegrationService extends IpcService { static override readonly groupName = "integration" @IpcMethod() async saveToObsidian( context: IpcContext, input: { url: string title: string content: string author: string publishedAt: string vaultPath: string }, ) { try { const { url, title, content, author, publishedAt, vaultPath } = input const fileName = `${sanitizeFileName(title || publishedAt) .trim() .slice(0, 20)}.md` const filePath = path.join(vaultPath, fileName) const exists = existsSync(filePath) if (exists) { return { success: false, error: "File already exists" } } const markdown = `--- url: ${url} author: ${author} publishedAt: ${publishedAt} --- # ${title} ${content} ` await fsp.writeFile(filePath, markdown, "utf-8") return { success: true } } catch (error) { console.error("Failed to save to Obsidian:", error) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } } @IpcMethod() async saveToEagle(context: IpcContext, input: SaveToEagleInput): Promise<any> { try { const res = await fetch("http://localhost:41595/api/item/addFromURLs", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ items: input.mediaUrls?.map((media) => ({ url: media, website: input.url, headers: { referer: input.url, }, })), }), }) return await res.json() } catch { return null } } @IpcMethod() async loginToQBittorrent(context: IpcContext, input: LoginToQBittorrentInput) { const { host, username, password } = input const existingSID = store.get("qbittorrentSID") if (existingSID) { const errorMessage = await this.checkQBittorrentAuth(context, { host }) if (!errorMessage) { return } } const res = await fetch(`${host}/api/v2/auth/login`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, }) if (!res.ok) { return `Failed to log in to qBittorrent: ${await res.text()}` } const cookies = res.headers.get("set-cookie") || "" const match = cookies.match(/SID=([^;]+)/) if (!match || !match[1]) { return "Failed to get SID from qBittorrent" } store.set("qbittorrentSID", match[1]) return } async checkQBittorrentAuth(context: IpcContext, input: CheckQBittorrentAuthInput) { const { host } = input const sid = store.get("qbittorrentSID") if (!sid) { return "Not logged in to qBittorrent" } const res = await fetch(`${host}/api/v2/auth/check`, { method: "GET", headers: { Cookie: `SID=${sid}`, }, credentials: "omit", }) if (!res.ok) { return await res.text() } } @IpcMethod() async addMagnet(context: IpcContext, input: AddMagnetInput) { const { host, urls } = input const sid = store.get("qbittorrentSID") if (!sid) { return "Not logged in to qBittorrent" } const res = await fetch(`${host}/api/v2/torrents/add`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: `SID=${sid}`, }, credentials: "omit", body: `urls=${encodeURIComponent(urls.join("\n"))}`, }) if (!res.ok) { const text = await res.text() return `Failed to add magnet links: ${text}` } // eslint-disable-next-line no-console console.log(`Added magnet links to qBittorrent: ${urls.join(", ")}`) } @IpcMethod() async customFetch(context: IpcContext, input: CustomFetchInput) { const requestId = Math.random().toString(36).slice(2, 8) const { url, method, headers, body, timeout = 10_000 } = input // Log request start logger.info(`[CustomFetch:${requestId}] Starting request`, { url: url.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) => // Mask potential sensitive query parameters key.toLowerCase().includes("token") || key.toLowerCase().includes("key") || key.toLowerCase().includes("password") ? `${prefix}${key}=***` : `${prefix}${key}=${value}`, ), method, timeout, hasBody: !!body, bodyLength: body?.length || 0, headerCount: Object.keys(headers || {}).length, }) // Log request headers (mask sensitive headers) const safeHeaders = { ...headers } Object.keys(safeHeaders).forEach((key) => { if ( key.toLowerCase().includes("authorization") || key.toLowerCase().includes("token") || key.toLowerCase().includes("key") ) { safeHeaders[key] = "***" } }) logger.debug(`[CustomFetch:${requestId}] Request headers`, { headers: safeHeaders }) // Log request body (truncated for large bodies) if (body) { const truncatedBody = body.length > 500 ? `${body.slice(0, 500)}... [truncated, total: ${body.length} chars]` : body logger.debug(`[CustomFetch:${requestId}] Request body`, { body: truncatedBody }) } const startTime = Date.now() try { const controller = new AbortController() const timeoutId = setTimeout(() => { logger.warn(`[CustomFetch:${requestId}] Request timeout triggered after ${timeout}ms`) controller.abort() }, timeout) logger.debug(`[CustomFetch:${requestId}] Sending request...`) const response = await fetch(url, { method, headers, body: body && ["POST", "PUT", "PATCH"].includes(method.toUpperCase()) ? body : undefined, signal: controller.signal, }) clearTimeout(timeoutId) const duration = Date.now() - startTime // Log response info logger.info(`[CustomFetch:${requestId}] Request completed`, { status: response.status, statusText: response.statusText, ok: response.ok, duration: `${duration}ms`, }) // Convert response headers to plain object const responseHeaders: Record<string, string> = {} response.headers.forEach((value, key) => { responseHeaders[key] = value }) logger.debug(`[CustomFetch:${requestId}] Response headers`, { headers: responseHeaders, contentType: responseHeaders["content-type"], contentLength: responseHeaders["content-length"], }) // Get response text const text = await response.text() const responseSize = text.length logger.debug(`[CustomFetch:${requestId}] Response body received`, { size: `${responseSize} chars`, preview: text.length > 200 ? `${text.slice(0, 200)}...` : text, }) // Try to parse as JSON, fallback to text let data: any try { data = JSON.parse(text) logger.debug(`[CustomFetch:${requestId}] Response successfully parsed as JSON`) } catch { data = text logger.debug(`[CustomFetch:${requestId}] Response kept as text (not valid JSON)`) } const result = { ok: response.ok, status: response.status, statusText: response.statusText, headers: responseHeaders, data, text, } logger.info(`[CustomFetch:${requestId}] Request successful`, { finalStatus: result.ok ? "success" : "http_error", responseSize: `${responseSize} chars`, totalDuration: `${Date.now() - startTime}ms`, }) return result } catch (error) { const duration = Date.now() - startTime if (error instanceof Error && error.name === "AbortError") { logger.error(`[CustomFetch:${requestId}] Request timeout`, { duration: `${duration}ms`, timeout: `${timeout}ms`, url: url.split("?")[0], // Remove query params for privacy }) throw new Error(`Request timeout after ${timeout}ms`) } logger.error(`[CustomFetch:${requestId}] Request failed`, { error: error instanceof Error ? error.message : String(error), errorName: error instanceof Error ? error.name : "Unknown", duration: `${duration}ms`, url: url.split("?")[0], // Remove query params for privacy }) throw error } } @IpcMethod() async openURLScheme(context: IpcContext, scheme: string) { const requestId = Math.random().toString(36).slice(2, 8) try { // Validate URL scheme format if (!scheme.includes("://")) { throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')") } // Log URL scheme execution (mask sensitive data) const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) => // Mask potential sensitive query parameters key.toLowerCase().includes("token") || key.toLowerCase().includes("key") || key.toLowerCase().includes("password") ? `${prefix}${key}=***` : `${prefix}${key}=${value}`, ) logger.info(`[URLScheme:${requestId}] Opening URL scheme`, { scheme: safeScheme, protocol: scheme.split("://")[0], }) // Use Electron's shell.openExternal to open URL scheme // This will trigger the system's default handler for the scheme await shell.openExternal(scheme) logger.info(`[URLScheme:${requestId}] URL scheme opened successfully`) return { success: true } } catch (error) { logger.error(`[URLScheme:${requestId}] Failed to open URL scheme`, { error: error instanceof Error ? error.message : String(error), scheme: scheme.split("://")[0], // Only log protocol for privacy }) throw error } } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/menu.ts ================================================ import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron" import { dialog, Menu, ShareMenu } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" type SerializableMenuItem = Omit<MenuItemConstructorOptions, "click" | "submenu"> & { submenu?: SerializableMenuItem[] } interface ShowContextMenuInput { items: SerializableMenuItem[] } interface ShowConfirmDialogInput { title: string message: string options?: Partial<MessageBoxOptions> } export class MenuService extends IpcService { static override readonly groupName = "menu" private normalizeMenuItems( items: SerializableMenuItem[], context: IpcContext, path: number[] = [], ): MenuItemConstructorOptions[] { return items.map((item, index) => { const curPath = [...path, index] return { ...item, click() { context.sender.send("menu-click", { id: item.id, path: curPath, }) }, submenu: item.submenu ? this.normalizeMenuItems(item.submenu, context, curPath) : undefined, } }) } @IpcMethod() async showContextMenu(context: IpcContext, input: ShowContextMenuInput): Promise<void> { const defer = Promise.withResolvers<void>() const normalizedMenuItems = this.normalizeMenuItems(input.items, context) const menu = Menu.buildFromTemplate(normalizedMenuItems) menu.popup({ callback: () => defer.resolve(), }) return defer.promise } @IpcMethod() async showConfirmDialog(_context: IpcContext, input: ShowConfirmDialogInput): Promise<boolean> { const result = await dialog.showMessageBox({ message: input.title, detail: input.message, buttons: ["Confirm", "Cancel"], ...input.options, }) return result.response === 0 } @IpcMethod() async showShareMenu(context: IpcContext, input: string): Promise<void> { const menu = new ShareMenu({ urls: [input], }) menu.popup({ callback: () => { context.sender.send("menu-closed") }, }) } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/reader.ts ================================================ import fs from "node:fs" import { callWindowExpose } from "@follow/shared/bridge" import { readability } from "@follow-app/readability" import { app, BrowserWindow } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts" import path from "pathe" import type { ModelResult } from "vscode-languagedetection" import { detectCodeStringLanguage } from "../../modules/language-detection" const tts = new MsEdgeTTS() interface ReadabilityInput { url: string html?: string } interface TtsInput { id: string text: string voice: string } interface DetectCodeStringLanguageInput { codeString: string } export class ReaderService extends IpcService { static override readonly groupName = "reader" @IpcMethod() async readability(_context: IpcContext, input: ReadabilityInput) { const { url } = input if (!url) { return null } const result = await readability(url) return result } @IpcMethod() async tts(context: IpcContext, input: TtsInput): Promise<string | null> { const { id, text, voice } = input if (!text) { return null } const window = BrowserWindow.fromWebContents(context.sender) if (!window) return null try { await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, {}) } catch (error: unknown) { console.error("Failed to set voice", error) if (error instanceof Error) { callWindowExpose(window).toast.error(error.message, { duration: 1000, }) } else { callWindowExpose(window).toast.error("Failed to set voice", { duration: 1000, }) } return null } const dirPath = path.join(app.getPath("userData"), "Cache", "tts", id) const possibleFilePathList = ["mp3", "webm"].map((ext) => { return path.join(dirPath, `audio.${ext}`) }) const filePath = possibleFilePathList.find((p) => fs.existsSync(p)) if (filePath) { return filePath } else { fs.mkdirSync(dirPath, { recursive: true }) const { audioFilePath } = await tts.toFile(dirPath, text) return audioFilePath } } @IpcMethod() async getVoices(context: IpcContext) { const window = BrowserWindow.fromWebContents(context.sender) try { const voices = await tts.getVoices() return voices } catch (error) { console.error("Failed to get voices", error) if (!window) return if (error instanceof Error) { void callWindowExpose(window).toast.error(error.message, { duration: 1000 }) return } callWindowExpose(window).toast.error("Failed to get voices", { duration: 1000 }) } } @IpcMethod() async detectCodeStringLanguage( _context: IpcContext, input: DetectCodeStringLanguageInput, ): Promise<ModelResult | undefined> { const { codeString } = input const languages = detectCodeStringLanguage(codeString) let finalLanguage: ModelResult | undefined for await (const language of languages) { if (!finalLanguage) { finalLanguage = language continue } if (language.confidence > finalLanguage.confidence) { finalLanguage = language } } return finalLanguage } } ================================================ FILE: apps/desktop/layer/main/src/ipc/services/setting.ts ================================================ import { createRequire } from "node:module" import { app, nativeTheme } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { WindowManager } from "~/manager/window" import { setProxyConfig, updateProxy } from "../../lib/proxy" import { store } from "../../lib/store" import { getTrayConfig, setTrayConfig } from "../../lib/tray" const require = createRequire(import.meta.url) interface SetLoginItemSettingsInput { openAtLogin: boolean openAsHidden?: boolean path?: string args?: string[] } export class SettingService extends IpcService { static override readonly groupName = "setting" @IpcMethod() getLoginItemSettings(_context: IpcContext): Electron.LoginItemSettings { return app.getLoginItemSettings() } @IpcMethod() setLoginItemSettings(_context: IpcContext, input: SetLoginItemSettingsInput): void { app.setLoginItemSettings(input) } @IpcMethod() openSettingWindow(_context: IpcContext): void { WindowManager.showSetting() } @IpcMethod() async getSystemFonts(_context: IpcContext): Promise<string[]> { const fonts = await require("font-list").getFonts() return fonts.map((font: string) => font.replaceAll('"', "")) } @IpcMethod() getAppearance(_context: IpcContext): "light" | "dark" | "system" { return nativeTheme.themeSource } @IpcMethod() setAppearance(_context: IpcContext, appearance: "light" | "dark" | "system"): void { nativeTheme.themeSource = appearance store.set("appearance", appearance) } @IpcMethod() getMinimizeToTray(_context: IpcContext): boolean { return getTrayConfig() } @IpcMethod() setMinimizeToTray(_context: IpcContext, minimize: boolean): void { setTrayConfig(minimize) } @IpcMethod() getProxyConfig(_context: IpcContext) { const proxy = store.get("proxy") return proxy ?? undefined } @IpcMethod() setProxyConfig(_context: IpcContext, config: string) { const result = setProxyConfig(config) updateProxy() return result } @IpcMethod() getMessagingToken(_context: IpcContext): string | null { return store.get("notifications-credentials") as string | null } } ================================================ FILE: apps/desktop/layer/main/src/lib/api-client.ts ================================================ import { env } from "@follow/shared/env.desktop" import { createDesktopAPIHeaders } from "@follow/utils/headers" import { FollowClient } from "@follow-app/client-sdk" import PKG, { mainHash, version as appVersion } from "@pkg" import { gte } from "semver" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { WindowManager } from "~/manager/window" import { getCurrentRendererManifest } from "~/updater/hot-updater" import { logger } from "../logger" export const followClient = new FollowClient({ credentials: "include", timeout: 10000, baseURL: env.VITE_API_URL, fetch: async (input, options = {}) => fetch(input.toString(), { ...options, cache: "no-store", }), }) export const apiClient = followClient.api followClient.addRequestInterceptor(async (ctx) => { const { options } = ctx const header = options.headers || {} const apiHeader = createDesktopAPIHeaders({ version: PKG.version }) const rendererManifest = getCurrentRendererManifest() const rendererVersion = gte(rendererManifest?.version ?? appVersion, appVersion) ? (rendererManifest?.version ?? appVersion) : appVersion // Get cookies for authentication const window = WindowManager.getMainWindow() const cookies = await window?.webContents.session.cookies.get({ domain: new URL(env.VITE_API_URL).hostname, }) const sessionCookie = cookies?.find((cookie) => cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN), ) const headerCookie = sessionCookie ? `${sessionCookie.name}=${sessionCookie.value}` : "" const userAgent = window?.webContents.getUserAgent() || `Folo/${PKG.version}` options.headers = { ...header, ...apiHeader, Cookie: headerCookie, "User-Agent": userAgent, "X-Follow-Main-Hash": mainHash, "X-Follow-Renderer-Version": rendererVersion, "X-Follow-App-Version": appVersion, "X-Follow-Platform": process.platform, } return ctx }) followClient.addResponseInterceptor(({ response }) => { logger.info(`API Response: ${response.status} ${response.statusText}`) return response }) followClient.addErrorInterceptor(async ({ response, error }) => { if (!response) { logger.error("API Request failed - no response", error) return error } }) followClient.addResponseInterceptor(async ({ response }) => { // Handle specific error cases if needed in main process if (response.status === 401) { logger.warn("Authentication failed in main process") } try { await response.clone().json() } catch (error) { logger.error("API Error details:", error) } return response }) ================================================ FILE: apps/desktop/layer/main/src/lib/auth-cookie-migration.ts ================================================ import type { Cookie, CookiesSetDetails, Session } from "electron" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { logger } from "../logger" const LEGACY_PROD_API_URL = "https://api.follow.is" const BETTER_AUTH_SESSION_DATA_COOKIE_NAME = "better-auth.session_data" const isBetterAuthSessionTokenCookie = (cookieName: string) => { return cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN) } const isBetterAuthSessionCookie = (cookieName: string) => { return ( cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN) || cookieName.includes(BETTER_AUTH_SESSION_DATA_COOKIE_NAME) ) } const toCookieSetDetails = (cookie: Cookie, url: string, domain: string): CookiesSetDetails => { const details: CookiesSetDetails = { url, name: cookie.name, value: cookie.value, domain, path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite, } if (!cookie.session && cookie.expirationDate) { details.expirationDate = cookie.expirationDate } return details } export const migrateAuthCookiesToNewApiDomain = async ( cookieSession: Session, options: { currentApiURL: string legacyApiURL?: string }, ) => { const legacyApiURL = options.legacyApiURL ?? LEGACY_PROD_API_URL if (!options.currentApiURL || options.currentApiURL === legacyApiURL) { return } const currentHost = new URL(options.currentApiURL).hostname const legacyHost = new URL(legacyApiURL).hostname if (currentHost === legacyHost) { return } const currentDomainCookies = await cookieSession.cookies.get({ domain: currentHost, }) const hasCurrentDomainSessionTokenCookie = currentDomainCookies.some((cookie) => isBetterAuthSessionTokenCookie(cookie.name), ) if (hasCurrentDomainSessionTokenCookie) { return } const legacyDomainCookies = await cookieSession.cookies.get({ domain: legacyHost, }) const legacySessionCookies = legacyDomainCookies.filter((cookie) => isBetterAuthSessionCookie(cookie.name), ) if (legacySessionCookies.length === 0) { return } await Promise.all( legacySessionCookies.map((cookie) => { return cookieSession.cookies.set( toCookieSetDetails(cookie, options.currentApiURL, currentHost), ) }), ) logger.info( `Migrated ${legacySessionCookies.length} auth cookie(s) from ${legacyHost} to ${currentHost}`, ) } ================================================ FILE: apps/desktop/layer/main/src/lib/cleaner.ts ================================================ import { statSync } from "node:fs" import fsp from "node:fs/promises" import { callWindowExpose } from "@follow/shared/bridge" import { app, dialog } from "electron" import path from "pathe" import { getIconPath } from "~/helper" import { logger } from "~/logger" import { WindowManager } from "~/manager/window" import { t } from "./i18n" import { store, StoreKey } from "./store" const getFolderSize = async (dir: string): Promise<number> => { try { const files = await fsp.readdir(dir, { withFileTypes: true }) const sizes = await Promise.all( files.map(async (file) => { const filePath = path.join(dir, file.name) if (file.isSymbolicLink()) { return 0 } if (file.isDirectory()) { return await getFolderSize(filePath) } if (file.isFile()) { try { const { size } = await fsp.stat(filePath) return size } catch { return 0 } } return 0 }), ) return sizes.reduce((acc, size) => acc + size, 0) } catch { return 0 } } export const clearAllDataAndConfirm = async () => { const win = WindowManager.getMainWindow() if (!win) return // Dialog to confirm const result = await dialog.showMessageBox({ type: "warning", icon: getIconPath(), message: t("dialog.clearAllData"), buttons: [t("dialog.yes"), t("dialog.no")], cancelId: 1, }) if (result.response === 1) { return } return clearAllData() } export const clearAllData = async () => { const win = WindowManager.getMainWindow() if (!win) return const ses = win.webContents.session const caller = callWindowExpose(win) try { await ses.clearCache() await ses.clearStorageData({ storages: [ "websql", "filesystem", "indexdb", "localstorage", "shadercache", "websql", "serviceworkers", "cookies", ], }) caller.toast.success("App data reset successfully") // reload the app win.reload() } catch (error: any) { caller.toast.error(`Error resetting app data: ${error.message}`) } } export const getCacheSize = async () => { const cachePath = path.join(app.getPath("userData"), "cache") // Size is in bytes const sizeInBytes = await getFolderSize(cachePath).catch((error) => { logger.error(error) }) return sizeInBytes || 0 } const getCachedFilesRecursive = async (dir: string, result: string[] = []) => { const files = await fsp.readdir(dir) for (const file of files) { const filePath = path.join(dir, file) const stat = await fsp.stat(filePath) if (stat.isDirectory()) { const files = await getCachedFilesRecursive(filePath) result.push(...files) } else { result.push(filePath) } } return result } let timer: any = null export const clearCacheCronJob = () => { if (timer) { timer = clearInterval(timer) } timer = setInterval( async () => { const hasLimit = store.get(StoreKey.CacheSizeLimit) if (!hasLimit) { return } const cacheSize = await getCacheSize() const limitByteSize = hasLimit * 1024 * 1024 if (cacheSize > limitByteSize) { const shouldCleanSize = cacheSize - limitByteSize - 1024 * 1024 * 50 // 50MB const cachePath = path.join(app.getPath("userData"), "cache") const files = await getCachedFilesRecursive(cachePath) // Sort by last modified files.sort((a, b) => { const aStat = statSync(a) const bStat = statSync(b) return bStat.mtime.getTime() - aStat.mtime.getTime() }) let cleanedSize = 0 for (const file of files) { try { const fileSize = statSync(file).size await fsp.rm(file, { force: true }) cleanedSize += fileSize if (cleanedSize >= shouldCleanSize) { logger.info(`Cleaned ${cleanedSize} bytes cache`) break } } catch (error) { logger.error(`Failed to delete cache file ${file}:`, error) } } } }, 10 * 60 * 1000, ) // 10 min return () => { if (!timer) return timer = clearInterval(timer) } } export const checkAndCleanCodeCache = async () => { const cachePath = path.join(app.getPath("userData"), "Code Cache") const size = await getFolderSize(cachePath).catch((error) => { logger.error(error) }) if (!size) return const threshold = 1024 * 1024 * 100 // 100MB if (size > threshold) { await fsp .rm(cachePath, { force: true, recursive: true }) .then(() => { logger.info(`Cleaned ${size} bytes code cache`) }) .catch((error) => { logger.error(`clean code cache failed: ${error.message}`) }) } } ================================================ FILE: apps/desktop/layer/main/src/lib/cli-session-sync.ts ================================================ import { execFile } from "node:child_process" import { mkdir, readFile, writeFile } from "node:fs/promises" import { homedir } from "node:os" import { promisify } from "node:util" import { env } from "@follow/shared/env.desktop" import { join } from "pathe" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { WindowManager } from "~/manager/window" import { logger } from "../logger" const execFileAsync = promisify(execFile) export const CLI_NPM_PACKAGE_NAME = "folocli" const CLI_NPX_PACKAGE_SPEC = `${CLI_NPM_PACKAGE_NAME}@latest` const CLI_CONFIG_DIR = join(homedir(), ".folo") const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json") const getNpxCommand = () => (process.platform === "win32" ? "npx.cmd" : "npx") export interface CliConfig { token?: string apiUrl?: string } export const readCliConfig = async (): Promise<CliConfig> => { try { const raw = await readFile(CLI_CONFIG_PATH, "utf8") return JSON.parse(raw) as CliConfig } catch { return {} } } const writeCliConfig = async (config: CliConfig): Promise<void> => { await mkdir(CLI_CONFIG_DIR, { recursive: true }) await writeFile(CLI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8") } export const getCliConfigPath = () => CLI_CONFIG_PATH export const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help` export const getCliLoginCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <session-token>` const runCliCommand = async (args: string[]) => { await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], { windowsHide: true, timeout: 120_000, maxBuffer: 1024 * 1024, }) } export const isCliRunnerAvailable = async (): Promise<boolean> => { try { await execFileAsync(getNpxCommand(), ["--version"], { windowsHide: true, timeout: 10_000, maxBuffer: 128 * 1024, }) return true } catch { return false } } const clearCliConfigToken = async () => { const config = await readCliConfig() if (!config.token) { return } delete config.token await writeCliConfig(config) } export const getSessionTokenFromCookies = async (): Promise<string | undefined> => { const window = WindowManager.getMainWindow() if (!window) return undefined const cookies = await window.webContents.session.cookies.get({ domain: new URL(env.VITE_API_URL).hostname, }) const sessionCookie = cookies.find((cookie) => cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN), ) return sessionCookie?.value } export const syncSessionToCliConfig = async (token?: string): Promise<void> => { if (token) { const config = await readCliConfig() if (config.token === token && config.apiUrl === env.VITE_API_URL) { return } if (!(await isCliRunnerAvailable())) { throw new Error("npx is not available") } await runCliCommand(["login", "--token", token, "--api-url", env.VITE_API_URL]) logger.info("CLI login synced via npx") return } if (await isCliRunnerAvailable()) { try { await runCliCommand(["logout"]) logger.info("CLI login cleared via npx") return } catch (error) { logger.error( "Failed to clear CLI login via npx, falling back to local config cleanup:", error, ) } } await clearCliConfigToken() logger.info("CLI login cleared from local config") } ================================================ FILE: apps/desktop/layer/main/src/lib/dock.ts ================================================ import { app } from "electron" import { isWindows } from "../env" export const setDockCount = (input: number) => { // TODO use Electron Overlay API if (isWindows) return if (app.dock) { app.dock.setBadge(input === 0 ? "" : input < 10000 ? input.toString() : "9999+") } else { app.setBadgeCount(input) } } export const getDockCount = () => { if (isWindows) return null if (app.dock) { return app.dock.getBadge() } else { return app.getBadgeCount() } } ================================================ FILE: apps/desktop/layer/main/src/lib/download.ts ================================================ import { createHash } from "node:crypto" import { createWriteStream } from "node:fs" import { mkdir } from "node:fs/promises" import { pipeline } from "node:stream" import { promisify } from "node:util" import ky from "ky" import path from "pathe" const streamPipeline = promisify(pipeline) export interface DownloadOptions { url: string outputPath: string expectedHash?: string onProgress?: (downloadedSize: number, totalSize: number, percentage: number) => void onLog?: (message: string) => void } export async function downloadFile(url: string, dest: string) { const res = await fetch(url) // Check whether it responds successfully. if (!res.ok) { throw new Error(`Failed to fetch ${url}: ${res.statusText}`) } if (!res.body) { throw new Error(`Failed to get response body`) } await streamPipeline(res.body as any, createWriteStream(dest)) } export async function downloadFileWithProgress(options: DownloadOptions): Promise<boolean> { const { url, outputPath, expectedHash, onProgress, onLog } = options try { // Create download directory await mkdir(path.dirname(outputPath), { recursive: true }) let lastProgressTime = Date.now() const sha256 = expectedHash ? createHash("sha256") : null onLog?.(`Starting download: ${path.basename(outputPath)}`) // Use ky with onDownloadProgress const response = await ky.get(url, { onDownloadProgress: (progress) => { const now = Date.now() // Call progress callback every 500ms to avoid spam if (now - lastProgressTime > 500 || progress.percent === 1) { const percentage = progress.percent * 100 const downloadedMB = (progress.transferredBytes / 1024 / 1024).toFixed(2) const totalMB = (progress.totalBytes / 1024 / 1024).toFixed(2) onLog?.(`Download progress: ${percentage.toFixed(1)}% (${downloadedMB}/${totalMB} MB)`) // Call progress callback if provided if (onProgress) { onProgress(progress.transferredBytes, progress.totalBytes, percentage) } lastProgressTime = now } }, }) if (!response.ok) { onLog?.(`Failed to download file: ${response.status} ${response.statusText}`) return false } // Get the response as array buffer const arrayBuffer = await response.arrayBuffer() const buffer = Buffer.from(arrayBuffer) // Verify hash if provided if (expectedHash && sha256) { sha256.update(buffer) const hash = sha256.digest("hex") if (hash !== expectedHash) { onLog?.(`Hash verification failed. Expected: ${expectedHash}, Got: ${hash}`) return false } onLog?.("Hash verification passed") } // Write to file const writeStream = createWriteStream(outputPath) return new Promise<boolean>((resolve) => { writeStream.on("error", (error) => { onLog?.(`Write stream error: ${error}`) resolve(false) }) writeStream.on("finish", () => { onLog?.(`Download completed: ${outputPath}`) resolve(true) }) writeStream.end(buffer) }) } catch (error) { onLog?.(`Download error: ${error}`) return false } } // async function testDownload() { // console.info("Testing ky onDownloadProgress implementation...") // const result = await downloadFileWithProgress({ // url: "https://github.com/Innei/Follow/releases/download/desktop/v1.2.5/manifest.yml", // outputPath: path.resolve(os.tmpdir(), "follow-render-update", "manifest.yml"), // onLog(message) { // console.info(`[LOG] ${message}`) // }, // }) // console.info(`Download result: ${result}`) // } // testDownload().catch(console.error) ================================================ FILE: apps/desktop/layer/main/src/lib/i18n.ts ================================================ import i18next from "i18next" import { resources } from "../@types/resources" export const defaultNS = "native" export const i18n = i18next.createInstance() as typeof i18next i18n.init({ fallbackLng: { default: ["en"], "zh-TW": ["zh-CN", "en"], }, defaultNS, resources, }) export const { t } = i18n ================================================ FILE: apps/desktop/layer/main/src/lib/proxy.test.ts ================================================ import { session } from "electron" import type { Mock } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest" import { logger } from "../logger" import { getProxyConfig, setProxyConfig, updateProxy } from "./proxy" import { store } from "./store" vi.mock("electron", () => ({ session: { defaultSession: { setProxy: vi.fn(), }, }, })) vi.mock("./store", () => ({ store: { set: vi.fn(), get: vi.fn(), delete: vi.fn(), }, })) vi.mock("../logger", () => ({ logger: { log: vi.fn(), }, })) describe("proxy", () => { beforeEach(() => { vi.clearAllMocks() }) describe("setProxyConfig", () => { it("should set proxy config and return true for valid proxy", () => { const proxy = "http://localhost:8080" const result = setProxyConfig(proxy) expect(store.set).toHaveBeenCalledWith("proxy", "http://localhost:8080") expect(result).toBe(true) }) it("should set sock proxy config", () => { const proxy = "socks://localhost:8080" const result = setProxyConfig(proxy) expect(store.set).toHaveBeenCalledWith("proxy", "socks://localhost:8080") expect(result).toBe(true) }) it("should handle default port", () => { // https://github.com/RSSNext/Follow/issues/1197 const proxy = "http://example.com:80" const result = setProxyConfig(proxy) expect(store.set).toHaveBeenCalledWith("proxy", "http://example.com") expect(result).toBe(true) }) it("should return false for invalid proxy", () => { const proxy = "invalid-proxy" const result = setProxyConfig(proxy) expect(store.delete).toHaveBeenCalledWith("proxy") expect(result).toBe(false) }) }) describe("getProxyConfig", () => { it("should return normalized proxy config if set", () => { ;(store.get as Mock).mockReturnValue("http://localhost:8080") const result = getProxyConfig() expect(result).toBe("http://localhost:8080") }) it("should compatible dirty data", () => { ;(store.get as Mock).mockReturnValue("http://localhost:8080,direct://") const result = getProxyConfig() expect(result).toBe("http://localhost:8080") }) }) describe("updateProxy", () => { it("should set system proxy mode if no proxy config is set", () => { ;(store.get as Mock).mockReturnValue("") updateProxy() expect(session.defaultSession.setProxy).toHaveBeenCalledWith({ mode: "system" }) }) it("should set proxy rules if proxy config is set", () => { ;(store.get as Mock).mockReturnValue("http://localhost:8080") updateProxy() expect(logger.log).toHaveBeenCalledWith("Loading proxy: http://localhost:8080,direct://") expect(session.defaultSession.setProxy).toHaveBeenCalledWith({ proxyRules: "http://localhost:8080,direct://", proxyBypassRules: "<local>", }) }) }) }) ================================================ FILE: apps/desktop/layer/main/src/lib/proxy.ts ================================================ import { session } from "electron" import { ProxyAgent, setGlobalDispatcher } from "undici" import { logger } from "../logger" import { store } from "./store" // Sets up the proxy configuration for the app. // // See https://www.electronjs.org/docs/latest/api/session#sessetproxyconfig // for more information about the proxy API. // // The open-source project [poooi/poi](https://github.com/poooi/poi) is doing well in proxy configuration // refer the following files for more details: // // https://github.com/poooi/poi/blob/5741d0d02c0a08626dd53196b094223457014491/lib/proxy.ts#L36 // https://github.com/poooi/poi/blob/5741d0d02c0a08626dd53196b094223457014491/views/components/settings/network/index.es export const setProxyConfig = (inputProxy: string) => { const proxyUri = normalizeProxyUri(inputProxy) if (!proxyUri) { store.delete("proxy") return false } store.set("proxy", proxyUri) return true } export const getProxyConfig = () => { const proxyConfig = store.get("proxy") if (!proxyConfig) { return } const proxyUri = normalizeProxyUri(proxyConfig) return proxyUri } const URL_SCHEME = new Set(["http:", "https:", "ftp:", "socks:", "socks4:", "socks5:"]) const normalizeProxyUri = (userProxy: string) => { if (!userProxy) { return } // Only use the first proxy if there are multiple urls const firstInput = userProxy.split(",")[0]! try { const proxyUrl = new URL(firstInput) if (!URL_SCHEME.has(proxyUrl.protocol) || !proxyUrl.hostname) { return } // There are multiple ways to specify a proxy in Electron, // but for security reasons, we only support simple proxy URLs for now. return `${proxyUrl.protocol}//${proxyUrl.hostname}${proxyUrl.port ? `:${proxyUrl.port}` : ""}` } catch { return } } const BYPASS_RULES = ["<local>"].join(";") export const updateProxy = () => { const proxyUri = getProxyConfig() if (!proxyUri) { session.defaultSession.setProxy({ // Note that the system mode is different from setting no proxy configuration. // In the latter case, Electron falls back to the system settings only if no command-line options influence the proxy configuration. mode: "system", }) return } const proxyRules = [ proxyUri, // Failing over to using no proxy if the proxy is unavailable "direct://", ].join(",") logger.log(`Loading proxy: ${proxyRules}`) session.defaultSession.setProxy({ proxyRules, proxyBypassRules: BYPASS_RULES, }) // https://github.com/nodejs/undici/issues/2224 // Error occurred in handler for 'setProxyConfig': InvalidArgumentError: Invalid URL protocol: the URL must start with `http:` or `https:`. const { protocol } = new URL(proxyUri) if (protocol !== "http:" && protocol !== "https:") { // undici doesn't support socks proxy logger.warn("undici only supports http and https proxy, skipping undici proxy setup") return } // Currently, Session.setProxy is not working for native fetch, which is used by readability. // So we need to set proxy for native fetch manually, refer to https://stackoverflow.com/a/76503362/14676508 const dispatcher = new ProxyAgent({ uri: new URL(proxyUri).toString() }) setGlobalDispatcher(dispatcher) } ================================================ FILE: apps/desktop/layer/main/src/lib/router.ts ================================================ import { callWindowExpose } from "@follow/shared/bridge" import { extractElectronWindowOptions } from "@follow/shared/electron" import type { BrowserWindow } from "electron/main" import { logger } from "~/logger" import { WindowManager } from "~/manager/window" export const handleUrlRouting = (url: string) => { const options = extractElectronWindowOptions(url) // For example, the url is "follow://add?id=123&type=list&url=https://example.com" const doubleSlash = url.indexOf("://") if (doubleSlash === -1) { logger.error("url routing error: no protocol found", url) return } // Remove the protocol // For example, the uri is "/add?id=123&type=list&url=https://example.com" const uri = url.slice(doubleSlash + 2) try { const { pathname, searchParams } = new URL(uri, "https://follow.dev") const pathnameTrimmed = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname switch (pathnameTrimmed) { case "/add": { callMainWindow(url, (mainWindow) => { const caller = callWindowExpose(mainWindow) const id = searchParams.get("id") ?? undefined const isList = searchParams.get("type") === "list" const urlParam = searchParams.get("url") ?? undefined if (!id && !urlParam) return caller.follow({ isList, id, url: urlParam }) }) return } case "/discover": { callMainWindow(url, (mainWindow) => { const caller = callWindowExpose(mainWindow) const route = searchParams.get("route") ?? undefined if (!route) return caller.rsshubRoute(route) }) return } case "/feed": { callMainWindow(url, (mainWindow) => { const caller = callWindowExpose(mainWindow) const id = searchParams.get("id") ?? undefined const view = searchParams.get("view") ?? "0" if (!id) return caller.goToFeed({ id, view: Number.parseInt(view, 10), }) }) return } case "/list": { callMainWindow(url, (mainWindow) => { const caller = callWindowExpose(mainWindow) const id = searchParams.get("id") ?? undefined const view = searchParams.get("view") ?? "0" if (!id) return caller.goToList({ id, view: Number.parseInt(view, 10), }) }) return } case "/refresh": { callMainWindow(url, (mainWindow) => { const caller = callWindowExpose(mainWindow) caller.refreshSession() }) return } case "/": { callMainWindow(url, (mainWindow) => { mainWindow.restore() mainWindow.focus() }) return } default: { const { height, resizable = true, width } = options || {} WindowManager.createWindow({ extraPath: `#${uri}`, width: width ?? 800, height: height ?? 700, minWidth: 600, minHeight: 600, resizable, }) return } } } catch (err) { logger.error("routing error:", err) } } const callMainWindow = (url: string, fn: (mainWindow: BrowserWindow) => any) => { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) { WindowManager.createMainWindow() return handleUrlRouting(url) } mainWindow.restore() mainWindow.focus() fn(mainWindow) } ================================================ FILE: apps/desktop/layer/main/src/lib/store.ts ================================================ import type { Credentials } from "@eneris/push-receiver/dist/types" import Store from "electron-store" // @keep-sorted type StoreData = { "notifications-credentials"?: Credentials | null "notifications-persistent-ids"?: string[] | null appearance?: "light" | "dark" | "system" | null cacheSizeLimit?: number | null minimizeToTray?: boolean | null proxy?: string | null qbittorrentSID?: string | null user?: string | null windowState?: { height: number width: number x: number y: number } | null } export const store = new Store<StoreData>({ name: "db" }) export enum StoreKey { CacheSizeLimit = "cacheSizeLimit", } ================================================ FILE: apps/desktop/layer/main/src/lib/tray.ts ================================================ import { name } from "@pkg" import { app, Menu, nativeImage, Tray } from "electron" import { isMacOS, isMAS, isWindows } from "~/env" import { getTrayIconPath } from "~/helper" import { logger, revealLogFile } from "~/logger" import { WindowManager } from "~/manager/window" import { checkForAppUpdates } from "~/updater" import { getDockCount } from "./dock" import { t } from "./i18n" import { store } from "./store" // https://www.electronjs.org/docs/latest/tutorial/tray let tray: Tray | null = null const getTrayContextMenu = () => { const count = getDockCount() return Menu.buildFromTemplate([ ...(count ? [ { label: `${t("menu.unread")} ${count}`, enabled: false, }, ] : []), { label: t("menu.open", { name }), click: showWindow, }, { label: t("menu.help"), submenu: [ { label: t("menu.reload"), click: () => { const mainWindow = WindowManager.getMainWindowOrCreate() mainWindow.webContents.reload() }, }, { label: t("menu.toggleDevTools"), click: () => { const mainWindow = WindowManager.getMainWindowOrCreate() mainWindow.webContents.toggleDevTools() }, }, { label: t("menu.openLogFile"), click: async () => { await revealLogFile() }, }, ...(!isMAS ? [ { label: t("menu.checkForUpdates"), click: async () => { showWindow() await checkForAppUpdates() }, }, ] : []), ], }, { label: t("menu.quit", { name }), click: () => { logger.info("Quit app from tray") app.quit() }, }, ]) } export const registerAppTray = () => { if (!getTrayConfig()) return if (tray) { destroyAppTray() } const icon = nativeImage.createFromPath(getTrayIconPath()) // See https://stackoverflow.com/questions/41664208/electron-tray-icon-change-depending-on-dark-theme/41998326#41998326 const trayIcon = isMacOS ? icon.resize({ width: 16 }) : icon trayIcon.setTemplateImage(true) tray = new Tray(trayIcon) tray.setContextMenu(getTrayContextMenu()) tray.setToolTip(app.getName()) tray.on("mouse-enter", () => { tray?.setContextMenu(getTrayContextMenu()) }) if (isWindows) { tray.on("click", showWindow) } } const showWindow = () => { const mainWindow = WindowManager.getMainWindowOrCreate() if (mainWindow.isMinimized()) { mainWindow.restore() } else { mainWindow.show() } } const destroyAppTray = () => { if (tray) { tray.destroy() tray = null } } const DEFAULT_MINIMIZE_TO_TRAY = false export const getTrayConfig = () => store.get("minimizeToTray") ?? DEFAULT_MINIMIZE_TO_TRAY export const setTrayConfig = (input: boolean) => { store.set("minimizeToTray", input) if (input) { registerAppTray() } else { destroyAppTray() } } ================================================ FILE: apps/desktop/layer/main/src/lib/user.ts ================================================ import type { Credentials } from "@eneris/push-receiver/dist/types" import { isLinux, isMacOS, isWindows } from "~/env" import { logger } from "~/logger" import { apiClient } from "./api-client" import { store } from "./store" const notificationChannel = isMacOS ? "macos" : isWindows ? "windows" : isLinux ? "linux" : "desktop" export const updateNotificationsToken = async (newCredentials?: Credentials) => { if (newCredentials) { store.set("notifications-credentials", newCredentials) } const credentials = newCredentials || store.get("notifications-credentials") if (credentials?.fcm?.token) { try { await apiClient.messaging.createToken({ token: credentials.fcm.token, channel: notificationChannel, }) logger.info("updateNotificationsToken success: ", credentials.fcm.token) } catch (error) { logger.error("updateNotificationsToken error: ", error) } } } export const deleteNotificationsToken = async () => { await apiClient.messaging.deleteToken({ channel: notificationChannel, }) } ================================================ FILE: apps/desktop/layer/main/src/lib/utils.ts ================================================ import type { BrowserWindow } from "electron" export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) // To solve the vibrancy losing issue when leaving full screen mode // @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157 export function refreshBound(window: BrowserWindow, timeout = 0) { setTimeout(() => { // FIXME: workaround for theme bug in full screen mode const size = window?.getSize() window?.setSize(size[0]! + 1, size[1]! + 1) window?.setSize(size[0]!, size[1]!) }, timeout) } ================================================ FILE: apps/desktop/layer/main/src/logger.ts ================================================ import { app, shell } from "electron" import log from "electron-log" export const logger = log.scope("main") log.initialize() export function getLogFilePath() { return log.transports.file.getFile().path } export async function revealLogFile() { const filePath = getLogFilePath() return await shell.openPath(filePath) } app.on("before-quit", () => { logger.info("App is quitting") log.transports.console.level = false }) app.on("will-quit", () => { logger.info("App will quit") }) ================================================ FILE: apps/desktop/layer/main/src/manager/app.ts ================================================ import { PushReceiver } from "@eneris/push-receiver" import { callWindowExpose } from "@follow/shared/bridge" import { APP_PROTOCOL, DEV, LEGACY_APP_PROTOCOL } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { app, nativeTheme, Notification, shell } from "electron" import contextMenu from "electron-context-menu" import path from "pathe" import { WindowManager } from "~/manager/window" import { getIconPath } from "../helper" import { initializeIpcServices } from "../ipc" import { checkAndCleanCodeCache, clearCacheCronJob } from "../lib/cleaner" import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../lib/cli-session-sync" import { t } from "../lib/i18n" import { updateProxy } from "../lib/proxy" import { store } from "../lib/store" import { registerAppTray } from "../lib/tray" import { updateNotificationsToken } from "../lib/user" import { logger } from "../logger" import { registerAppMenu } from "../menu" import { registerUpdater } from "../updater" import { LifecycleManager } from "./lifecycle" class AppManagerStatic { private static instance: AppManagerStatic public static getInstance(): AppManagerStatic { if (!AppManagerStatic.instance) { AppManagerStatic.instance = new AppManagerStatic() } return AppManagerStatic.instance } public init() { initializeIpcServices() LifecycleManager.onReady(this.onReady.bind(this)) } private onReady() { this.registerProtocols() this.setupAppVisuals() this.setupSystemConfigs() this.runCronJobs() this.registerMenuAndContextMenu() this.registerPushNotifications() updateProxy() registerUpdater() registerAppTray() // Sync the desktop session to the npm CLI after cookies are ready. setTimeout(async () => { try { const token = await getSessionTokenFromCookies() if (token) { await syncSessionToCliConfig(token) } } catch (err) { logger.error("Failed to sync session to CLI on startup:", err) } }, 5000) } private registerProtocols() { const protocols = [LEGACY_APP_PROTOCOL, APP_PROTOCOL] for (const protocolName of protocols) { if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient(protocolName, process.execPath, [ path.resolve(process.argv[1]!), ]) } } else { app.setAsDefaultProtocolClient(protocolName) } } } private setupAppVisuals() { if (app.dock) { app.dock.setIcon(getIconPath()) } } private setupSystemConfigs() { const appearance = store.get("appearance") if (appearance && ["light", "dark", "system"].includes(appearance)) { nativeTheme.themeSource = appearance } } private runCronJobs() { clearCacheCronJob() checkAndCleanCodeCache() } private async registerPushNotifications() { if (!env.VITE_FIREBASE_CONFIG) { return } const credentialsKey = "notifications-credentials" const persistentIdsKey = "notifications-persistent-ids" const credentials = store.get(credentialsKey) const persistentIds = store.get(persistentIdsKey) const instance = new PushReceiver({ debug: true, firebase: JSON.parse(env.VITE_FIREBASE_CONFIG), persistentIds: persistentIds || [], credentials: credentials || undefined, bundleId: "is.follow", chromeId: "is.follow", }) logger.info( `PushReceiver initialized with credentials ${JSON.stringify(credentials)} and firebase config ${ env.VITE_FIREBASE_CONFIG }`, ) instance.onReady(() => { logger.info("PushReceiver ready") }) instance.onCredentialsChanged(({ newCredentials }) => { logger.info(`PushReceiver credentials changed to ${newCredentials?.fcm?.token}`) updateNotificationsToken(newCredentials) }) instance.onNotification((notification) => { logger.info( `PushReceiver received notification: ${JSON.stringify(notification.message.data)}`, ) const { data } = notification.message if (!data) { return } switch (data.type) { case "new-entry": { const notification = new Notification({ title: data.title as string, body: data.description as string, }) notification.on("click", () => { const mainWindow = WindowManager.getMainWindowOrCreate() mainWindow.restore() mainWindow.focus() const handlers = callWindowExpose(mainWindow) handlers.navigateEntry({ feedId: data.feedId as string, entryId: data.entryId as string, view: Number.parseInt(data.view as string), }) }) notification.show() break } default: { break } } store.set(persistentIdsKey, instance.persistentIds) }) try { await instance.connect() } catch (error) { logger.error(`PushReceiver error: ${error instanceof Error ? error.stack : error}`) } logger.info("PushReceiver connected") } private contextMenuDisposer?: () => void public registerMenuAndContextMenu() { registerAppMenu() if (this.contextMenuDisposer) { this.contextMenuDisposer() } this.contextMenuDisposer = contextMenu({ showSaveImageAs: true, showCopyLink: true, showCopyImageAddress: true, showCopyImage: true, showInspectElement: DEV, showSelectAll: true, showCopyVideoAddress: true, showSaveVideoAs: true, labels: { saveImageAs: t("contextMenu.saveImageAs"), copyLink: t("contextMenu.copyLink"), copyImageAddress: t("contextMenu.copyImageAddress"), copyImage: t("contextMenu.copyImage"), copyVideoAddress: t("contextMenu.copyVideoAddress"), saveVideoAs: t("contextMenu.saveVideoAs"), inspect: t("contextMenu.inspect"), copy: t("contextMenu.copy"), cut: t("contextMenu.cut"), paste: t("contextMenu.paste"), saveImage: t("contextMenu.saveImage"), saveVideo: t("contextMenu.saveVideo"), selectAll: t("contextMenu.selectAll"), services: t("contextMenu.services"), searchWithGoogle: t("contextMenu.searchWithGoogle"), learnSpelling: t("contextMenu.learnSpelling"), lookUpSelection: t("contextMenu.lookUpSelection"), saveLinkAs: t("contextMenu.saveLinkAs"), }, prepend: (_defaultActions, params) => { return [ { label: t("contextMenu.openImageInBrowser"), visible: params.mediaType === "image", click: () => { shell.openExternal(params.srcURL) }, }, { label: t("contextMenu.openLinkInBrowser"), visible: params.linkURL !== "", click: () => { shell.openExternal(params.linkURL) }, }, { role: "undo", label: t("menu.undo"), accelerator: "CmdOrCtrl+Z", visible: params.isEditable, }, { role: "redo", label: t("menu.redo"), accelerator: "CmdOrCtrl+Shift+Z", visible: params.isEditable, }, ] }, }) } } export const AppManager = AppManagerStatic.getInstance() ================================================ FILE: apps/desktop/layer/main/src/manager/bootstrap.ts ================================================ import { rmSync } from "node:fs" import { electronApp, optimizer } from "@electron-toolkit/utils" import { callWindowExpose } from "@follow/shared/bridge" import { DEV, LEGACY_APP_PROTOCOL } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { createBuildSafeHeaders } from "@follow/utils/headers" import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy" import { parse } from "cookie-es" import { app, BrowserWindow, net, protocol, session } from "electron" import { join } from "pathe" import { WindowManager } from "~/manager/window" import { isMacOS } from "../env" import { migrateAuthCookiesToNewApiDomain } from "../lib/auth-cookie-migration" import { handleUrlRouting } from "../lib/router" import { store } from "../lib/store" import { updateNotificationsToken } from "../lib/user" import { logger } from "../logger" import { cleanupOldRender } from "../updater/hot-updater" import { AppManager } from "./app" const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL const buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [ IMAGE_PROXY_URL, env.VITE_API_URL, "https://readwise.io", ]) export class BootstrapManager { public static start() { AppManager.init() const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.quit() return } this.registerAppEvents() } private static registerAppEvents() { app.on("second-instance", (_, commandLine) => { const mainWindow = WindowManager.getMainWindow() if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.show() } const url = commandLine.pop() if (url) { this.handleOpen(url) } }) app.whenReady().then(async () => { protocol.handle("app", (request) => { try { const urlObj = new URL(request.url) return net.fetch(`file://${urlObj.pathname}`) } catch { logger.error("app protocol error", request.url) return new Response("Not found", { status: 404 }) } }) app.on("browser-window-created", (_, window) => { optimizer.watchWindowShortcuts(window) }) electronApp.setAppUserModelId(`re.${LEGACY_APP_PROTOCOL}`) session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { details.requestHeaders = buildSafeHeaders({ url: details.url, headers: details.requestHeaders, }) callback({ cancel: false, requestHeaders: details.requestHeaders }) }) await migrateAuthCookiesToNewApiDomain(session.defaultSession, { currentApiURL: env.VITE_API_URL, }) // Bypass CORS for PostHog analytics session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const url = new URL(details.url) if (url.hostname === "us.i.posthog.com") { const responseHeaders = details.responseHeaders || {} responseHeaders["access-control-allow-origin"] = ["*"] responseHeaders["access-control-allow-methods"] = [ "GET", "POST", "PUT", "DELETE", "OPTIONS", ] responseHeaders["access-control-allow-headers"] = ["*"] responseHeaders["access-control-allow-credentials"] = ["true"] callback({ cancel: false, responseHeaders, }) } else { callback({ cancel: false }) } }) WindowManager.getMainWindowOrCreate() app.on("open-url", (_, url) => { const mainWindow = WindowManager.getMainWindowOrCreate() if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() } url && this.handleOpen(url) }) if (DEV) { this.installDevTools() } }) app.on("before-quit", async () => { const window = WindowManager.getMainWindow() if (!window || window.isDestroyed()) return const bounds = window.getBounds() store.set(WindowManager.windowStateStoreKey, { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, }) await session.defaultSession.cookies.flushStore() await cleanupOldRender() }) app.on("window-all-closed", () => { if (!isMacOS) { app.quit() } }) app.on("before-quit", () => { const windows = BrowserWindow.getAllWindows() windows.forEach((window) => window.destroy()) if (import.meta.env.DEV) { const cacheDir = join(app.getPath("userData"), "Cache") const codeCacheDir = join(app.getPath("userData"), "Code Cache") rmSync(cacheDir, { recursive: true, force: true }) rmSync(codeCacheDir, { recursive: true, force: true }) } }) } private static installDevTools() { import("electron-devtools-installer").then( ({ default: installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS }) => { ;[ REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS, { id: "acndjpgkpaclldomagafnognkcgjignd" }, ].forEach((extension) => { installExtension(extension, { loadExtensionOptions: { allowFileAccess: true }, }) .then((extension) => console.info(`Added Extension: ${extension.name}`)) .catch((err) => console.info("An error occurred:", err)) }) session.defaultSession.getAllExtensions().forEach((e) => { session.defaultSession.loadExtension(e.path) }) }, ) } private static async handleOpen(url: string) { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) return const isValid = URL.canParse(url) if (!isValid) return const urlObj = new URL(url) if (urlObj.hostname === "auth" || urlObj.pathname === "//auth") { const token = urlObj.searchParams.get("token") if (token) { await callWindowExpose(mainWindow).applyOneTimeToken(token) } else { const ck = urlObj.searchParams.get("ck") const userId = urlObj.searchParams.get("userId") if (ck && apiURL) { const cookie = parse(atob(ck), { decode: (value) => value }) Object.keys(cookie).forEach(async (name) => { const value = cookie[name]! await mainWindow.webContents.session.cookies.set({ url: apiURL, name, value, secure: true, httpOnly: true, domain: new URL(apiURL).hostname, sameSite: "no_restriction", expirationDate: new Date().setDate(new Date().getDate() + 30), }) }) if (userId) { await callWindowExpose(mainWindow).clearIfLoginOtherAccount(userId) } mainWindow.reload() updateNotificationsToken() } } } else { handleUrlRouting(url) } } } ================================================ FILE: apps/desktop/layer/main/src/manager/lifecycle.ts ================================================ import { app } from "electron" import { WindowManager } from "~/manager/window" class LifecycleManagerStatic { private static instance: LifecycleManagerStatic private constructor() { this.registerListeners() } public static getInstance(): LifecycleManagerStatic { if (!LifecycleManagerStatic.instance) { LifecycleManagerStatic.instance = new LifecycleManagerStatic() } return LifecycleManagerStatic.instance } private registerListeners() { app.on("window-all-closed", this.onWindowAllClosed.bind(this)) app.on("activate", this.onActivate.bind(this)) } private onWindowAllClosed() { if (process.platform !== "darwin") { app.quit() } } private onActivate() { const mainWindow = WindowManager.getMainWindowOrCreate() mainWindow.show() mainWindow.focus() } public onReady(callback: () => void) { if (app.isReady()) { callback() } else { app.on("ready", callback) } } } export const LifecycleManager = LifecycleManagerStatic.getInstance() ================================================ FILE: apps/desktop/layer/main/src/manager/window.ts ================================================ import { fileURLToPath } from "node:url" import { is } from "@electron-toolkit/utils" import { LEGACY_APP_PROTOCOL } from "@follow/shared" import { callWindowExpose, WindowState } from "@follow/shared/bridge" import { APP_PROTOCOL, DEV } from "@follow/shared/constants" import type { BrowserWindowConstructorOptions } from "electron" import { BrowserWindow, screen, shell } from "electron" import type { Event } from "electron/main" import path from "pathe" import { isMacOS, isWindows, isWindows11 } from "~/env" import { filePathToAppUrl, getIconPath } from "~/helper" import { t } from "~/lib/i18n" import { store } from "~/lib/store" import { getTrayConfig } from "~/lib/tray" import { refreshBound } from "~/lib/utils" import { logger } from "~/logger" import { loadDynamicRenderEntry } from "~/updater/hot-updater" const __dirname = fileURLToPath(new URL(".", import.meta.url)) class WindowManagerStatic { static get mainWindowDefaultSize() { const primaryDisplay = screen.getPrimaryDisplay() const { workAreaSize } = primaryDisplay return { height: workAreaSize.height, width: workAreaSize.width, } } // Window configuration properties for better DX private readonly config = { windowStateStoreKey: "windowState", minWindowSize: { width: 1024, height: 500, }, macOSTrafficLight: { x: 18, y: 18, }, refreshBoundDelay: 1000, devToolsFont: { family: 'consolas, operator mono, Cascadia Code, OperatorMonoSSmLig Nerd Font, "Agave Nerd Font", "Cascadia Code PL", monospace', size: "13px", }, ignoreProtocols: [ "http", "https", LEGACY_APP_PROTOCOL, APP_PROTOCOL, "file", "code", "cursor", "app", ] as const, vibrancy: { macOS: { type: "sidebar" as const, state: "followWindow" as const, }, }, windowPreferences: { preloadScript: path.join(__dirname, "../preload/index.mjs"), }, } as const readonly windowStateStoreKey = this.config.windowStateStoreKey private windows = { mainWindow: null as BrowserWindow | null, } private bindEvents(window: BrowserWindow) { window.on("leave-html-full-screen", () => { // To solve the vibrancy losing issue when leaving full screen mode // @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157 refreshBound(window) refreshBound(window, this.config.refreshBoundDelay) }) const parseProtocol = (url: string) => { try { return new URL(url).protocol.slice(0, -1) } catch { logger.warn("Blocked external URL with invalid format", { url }) return null } } const isIgnoredProtocol = ( protocol: string, ): protocol is (typeof this.config.ignoreProtocols)[number] => { return this.config.ignoreProtocols.includes( protocol as (typeof this.config.ignoreProtocols)[number], ) } const confirmAndOpenExternalProtocol = async (url: string) => { const caller = callWindowExpose(window) const confirm = await caller.dialog.ask({ title: t("dialog.openExternalApp.title"), message: t("dialog.openExternalApp.message", { url, interpolation: { escapeValue: false }, }), confirmText: t("dialog.open"), cancelText: t("dialog.cancel"), }) if (!confirm) { return } void shell.openExternal(url) } window.webContents.setWindowOpenHandler((details) => { const protocol = parseProtocol(details.url) if (!protocol) { return { action: "deny" } } if (protocol === "http" || protocol === "https") { void shell.openExternal(details.url) return { action: "deny" } } if (isIgnoredProtocol(protocol)) { logger.warn("Blocked window.open for ignored protocol", { protocol, url: details.url, }) return { action: "deny" } } void confirmAndOpenExternalProtocol(details.url) return { action: "deny" } }) const handleExternalProtocol = async (e: Event, url: string) => { const protocol = parseProtocol(url) if (!protocol) { e.preventDefault() return } if (isIgnoredProtocol(protocol)) { return } e.preventDefault() await confirmAndOpenExternalProtocol(url) } // Handle main window external links window.webContents.on("will-navigate", (e, url) => { void handleExternalProtocol(e, url) }) // Handle webview external links window.webContents.on("did-attach-webview", (_, webContents) => { webContents.on("will-navigate", (e, url) => { void handleExternalProtocol(e, url) }) }) if (isWindows) { // Change the default font-family and font-size of the devtools. // Make it consistent with Chrome on Windows, instead of SimSun. // ref: [[Feature Request]: Add possibility to change DevTools font · Issue #42055 · electron/electron](https://github.com/electron/electron/issues/42055) window.webContents.on("devtools-opened", () => { this.setupDevToolsFont(window) }) } this.bindWindowStateEvents(window) } private setupDevToolsFont(window: BrowserWindow) { // source-code-font: For code such as Elements panel // monospace-font: For sidebar such as Event Listener Panel 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};}` const js = ` const overriddenStyle = document.createElement('style'); overriddenStyle.innerHTML = '${css.replaceAll("\n", " ")}'; document.body.append(overriddenStyle); document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows')); addStyleToAutoComplete(); const observer = new MutationObserver((mutationList, observer) => { for (const mutation of mutationList) { if (mutation.type === 'childList') { for (let i = 0; i < mutation.addedNodes.length; i++) { const item = mutation.addedNodes[i]; if (item instanceof HTMLElement && item.classList.contains('editor-tooltip-host')) { addStyleToAutoComplete(); } } } } }); observer.observe(document.body, {childList: true}); function addStyleToAutoComplete() { document.querySelectorAll('.editor-tooltip-host').forEach(element => { if (element.shadowRoot && element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) { const overriddenStyle = document.createElement('style'); overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font'); overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}'; element.shadowRoot.append(overriddenStyle); } }); } ` window.webContents.devToolsWebContents?.executeJavaScript(js) } private bindWindowStateEvents(window: BrowserWindow) { // async render and main state window.on("maximize", async () => { const caller = callWindowExpose(window) await caller.setWindowState(WindowState.MAXIMIZED) }) window.on("unmaximize", async () => { const caller = callWindowExpose(window) await caller.setWindowState(WindowState.NORMAL) }) window.on("minimize", async () => { const caller = callWindowExpose(window) await caller.setWindowState(WindowState.MINIMIZED) }) window.on("restore", async () => { const caller = callWindowExpose(window) await caller.setWindowState(WindowState.NORMAL) }) } private bindMainWindowCloseHandlers(window: BrowserWindow) { window.on("close", () => { if (isWindows11) { const windowStoreKey = Symbol.for("maximized") if (window[windowStoreKey]) { const stored = window[windowStoreKey] store.set(this.windowStateStoreKey, { width: stored.size[0], height: stored.size[1], x: stored.position[0], y: stored.position[1], }) return } } const bounds = window.getBounds() store.set(this.windowStateStoreKey, { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, }) }) window.on("close", (event) => { const minimizeToTray = getTrayConfig() if (isMacOS || minimizeToTray) { event.preventDefault() if (window.isFullScreen()) { window.once("leave-full-screen", () => { window.hide() }) window.setFullScreen(false) } else { window.hide() } const caller = callWindowExpose(window) caller.onWindowClose() } else { this.windows.mainWindow = null } }) } private getPlatformSpecificWindowConfig(): Partial<BrowserWindowConstructorOptions> { const { platform } = process switch (platform) { case "darwin": { return { titleBarStyle: "hiddenInset", trafficLightPosition: { x: this.config.macOSTrafficLight.x, y: this.config.macOSTrafficLight.y, }, vibrancy: this.config.vibrancy.macOS.type, visualEffectState: this.config.vibrancy.macOS.state, transparent: true, } } case "win32": { return { icon: getIconPath(), titleBarStyle: "hidden", // Electron material bug, comment this for now // backgroundMaterial: isWindows11 ? "mica" : undefined, frame: true, } } default: { return { icon: getIconPath(), } } } } createWindow = ( options: { extraPath?: string height: number width: number } & BrowserWindowConstructorOptions, ) => { const { extraPath, height, width, ...configs } = options const baseWindowConfig: Electron.BrowserWindowConstructorOptions = { width, height, show: false, resizable: configs?.resizable ?? true, autoHideMenuBar: true, alwaysOnTop: false, webPreferences: { preload: this.config.windowPreferences.preloadScript, sandbox: false, webviewTag: true, webSecurity: !DEV, nodeIntegration: true, contextIsolation: false, }, ...this.getPlatformSpecificWindowConfig(), } // Create the browser window. const window = new BrowserWindow({ ...baseWindowConfig, ...configs, }) this.bindEvents(window) // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { window.loadURL(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || "")) logger.log(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || "")) } else { // Production entry const dynamicRenderEntry = loadDynamicRenderEntry() if (dynamicRenderEntry) logger.info("load dynamic render entry", dynamicRenderEntry) const appLoadFileEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html") const appLoadEntry = `${filePathToAppUrl(appLoadFileEntry)}${options?.extraPath || ""}` window.loadURL(appLoadEntry) logger.log("load URL", appLoadEntry) } return window } private ensureWindowBoundsInScreen(windowState?: { width?: number height?: number x?: number y?: number }) { const primaryDisplay = screen.getPrimaryDisplay() const { workArea } = primaryDisplay const maxWidth = workArea.width const maxHeight = workArea.height const defaultSize = WindowManagerStatic.mainWindowDefaultSize const width = windowState?.width ? Math.min(windowState.width, maxWidth) : defaultSize.width const height = windowState?.height ? Math.min(windowState.height, maxHeight) : defaultSize.height const ensureInBounds = (value: number, min: number, max: number): number => { return Math.max(min, Math.min(value, max)) } const x = windowState?.x !== undefined ? ensureInBounds(windowState.x, workArea.x, workArea.x + workArea.width - width) : undefined const y = windowState?.y !== undefined ? ensureInBounds(windowState.y, workArea.y, workArea.y + workArea.height - height) : undefined return { width, height, x, y, maxWidth, maxHeight } } createMainWindow = () => { const windowState = store.get(this.windowStateStoreKey) as | { width?: number height?: number x?: number y?: number } | undefined const { width, height, x, y, maxWidth, maxHeight } = this.ensureWindowBoundsInScreen(windowState) const window = this.createWindow({ width, height, x, y, minWidth: Math.min(this.config.minWindowSize.width, maxWidth), minHeight: Math.min(this.config.minWindowSize.height, maxHeight), }) this.bindMainWindowCloseHandlers(window) this.windows.mainWindow = window return window } showSetting = (path?: string) => { // We need to open the setting modal in the main window when the main window exists, // if we open a new window then the state between the two windows will be out of sync. if (this.windows.mainWindow) { if (this.windows.mainWindow.isMinimized()) { this.windows.mainWindow.restore() } this.windows.mainWindow.show() callWindowExpose(this.windows.mainWindow).showSetting(path) return } else { this.windows.mainWindow = this.createMainWindow() this.windows.mainWindow.show() callWindowExpose(this.windows.mainWindow).showSetting(path) } } getMainWindow = () => this.windows.mainWindow getMainWindowOrCreate = () => { if (!this.windows.mainWindow) { return this.createMainWindow() } return this.windows.mainWindow } destroyMainWindow = () => { this.windows.mainWindow?.destroy() this.windows.mainWindow = null } } export const WindowManager = new WindowManagerStatic() ================================================ FILE: apps/desktop/layer/main/src/menu.ts ================================================ import { callWindowExpose } from "@follow/shared/bridge" import { DEV } from "@follow/shared/constants" import { dispatchEventOnWindow } from "@follow/shared/event" import { name } from "@pkg" import type { BrowserWindow, MenuItem, MenuItemConstructorOptions } from "electron" import { Menu } from "electron" import { isMacOS, isMAS } from "./env" import { clearAllDataAndConfirm } from "./lib/cleaner" import { t } from "./lib/i18n" import { revealLogFile } from "./logger" import { WindowManager } from "./manager/window" import { checkForAppUpdates, quitAndInstall } from "./updater" export const registerAppMenu = () => { const menus: Array<MenuItemConstructorOptions | MenuItem> = [ ...(isMacOS ? ([ { label: name, submenu: [ { type: "normal", label: t("menu.about", { name }), click: () => { WindowManager.showSetting("about") }, }, { type: "separator" }, { label: t("menu.settings"), accelerator: "CmdOrCtrl+,", click: () => WindowManager.showSetting(), }, { type: "separator" }, { role: "services", label: t("menu.services") }, { type: "separator" }, { role: "hide", label: t("menu.hide", { name }) }, { role: "hideOthers", label: t("menu.hideOthers") }, { type: "separator" }, { label: t("menu.clearAllData"), click: clearAllDataAndConfirm, }, { role: "quit", label: t("menu.quit", { name }) }, ], }, ] as MenuItemConstructorOptions[]) : []), { role: "fileMenu", label: t("menu.file"), submenu: [ { type: "normal", label: t("menu.quickAdd"), accelerator: "CmdOrCtrl+N", click: () => { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) return mainWindow.show() const caller = callWindowExpose(mainWindow) caller.quickAdd() }, }, { type: "normal", label: t("menu.discover"), accelerator: "CmdOrCtrl+T", click: () => { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) return mainWindow.show() const caller = callWindowExpose(mainWindow) caller.goToDiscover() }, }, { type: "separator" }, { role: "close", label: t("menu.close") }, ], }, { label: t("menu.edit"), submenu: [ { role: "undo", label: t("menu.undo") }, { role: "redo", label: t("menu.redo") }, { type: "separator" }, { role: "cut", label: t("menu.cut") }, { role: "copy", label: t("menu.copy") }, { role: "paste", label: t("menu.paste") }, { type: "separator" }, { type: "normal", label: t("menu.search"), accelerator: "CmdOrCtrl+F", click(_e, window) { if (!window) return dispatchEventOnWindow(window as BrowserWindow, "OpenSearch") }, }, ...((isMacOS ? [ { role: "pasteAndMatchStyle", label: t("menu.pasteAndMatchStyle") }, { role: "delete", label: t("menu.delete") }, { role: "selectAll", label: t("menu.selectAll") }, { type: "separator" }, { label: t("menu.speech"), submenu: [ { role: "startSpeaking", label: t("menu.startSpeaking") }, { role: "stopSpeaking", label: t("menu.stopSpeaking") }, ], }, ] : [ { role: "delete", label: t("menu.delete") }, { type: "separator" }, { role: "selectAll", label: t("menu.selectAll") }, ]) as MenuItemConstructorOptions[]), ], }, { role: "viewMenu", label: t("menu.view"), submenu: [ { role: "reload", label: t("menu.reload") }, { role: "forceReload", label: t("menu.forceReload") }, { role: "toggleDevTools", label: t("menu.toggleDevTools") }, { type: "separator" }, { role: "togglefullscreen", label: t("menu.toggleFullScreen") }, ], }, { role: "windowMenu", label: t("menu.window"), submenu: [ { role: "minimize", label: t("menu.minimize"), }, { role: "zoom", label: t("menu.zoom"), }, { type: "separator", }, { role: "front", label: t("menu.front"), }, { label: "Always on top", type: "checkbox", checked: WindowManager.getMainWindow()?.isAlwaysOnTop(), click: () => { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) return mainWindow.setAlwaysOnTop(!mainWindow.isAlwaysOnTop()) registerAppMenu() }, }, ], }, { role: "help", label: t("menu.help"), submenu: [ { label: t("menu.openLogFile"), click: async () => { await revealLogFile() }, }, ...(!isMAS ? [ { label: t("menu.checkForUpdates"), click: async () => { WindowManager.getMainWindow()?.show() await checkForAppUpdates() }, }, ] : []), ], }, ] if (DEV) { menus.push({ label: t("menu.debug"), submenu: [ { label: t("menu.followReleases"), click: () => { WindowManager.createWindow({ extraPath: `#add?url=${encodeURIComponent( "https://github.com/RSSNext/follow/releases.atom", )}`, width: 800, height: 600, }) }, }, { type: "normal", label: t("menu.quitAndInstallUpdate"), click() { quitAndInstall() }, }, ], }) } Menu.setApplicationMenu(Menu.buildFromTemplate(menus)) } ================================================ FILE: apps/desktop/layer/main/src/modules/language-detection/index.ts ================================================ // @see https://github.dev/microsoft/vscode/blob/main/src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker.ts import { createRequire } from "node:module" import type { ModelResult } from "vscode-languagedetection" import type vsld from "vscode-languagedetection" import { logger } from "~/logger" const expectedRelativeConfidence = 0.1 const positiveConfidenceCorrectionBucket1 = 0.05 const positiveConfidenceCorrectionBucket2 = 0.025 const negativeConfidenceCorrection = 0.5 const adjustLanguageConfidence = (modelResult: vsld.ModelResult): vsld.ModelResult => { switch (modelResult.languageId) { // For the following languages, we increase the confidence because // these are commonly used languages in VS Code and supported // by the model. case "js": case "html": case "json": case "ts": case "css": case "py": case "xml": case "php": { modelResult.confidence += positiveConfidenceCorrectionBucket1 break } // 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. case "cpp": case "sh": case "java": case "cs": case "c": { modelResult.confidence += positiveConfidenceCorrectionBucket2 break } // For the following languages, we need to be extra confident that the language is correct because // we've had issues like #131912 that caused incorrect guesses. To enforce this, we subtract the // negativeConfidenceCorrection from the confidence. // languages that are provided by default in VS Code case "bat": case "ini": case "makefile": case "sql": case "csv": case "toml": { // Other considerations for negativeConfidenceCorrection that // aren't built in but suportted by the model include: // * Assembly, TeX - These languages didn't have clear language modes in the community // * Markdown, Dockerfile - These languages are simple but they embed other languages modelResult.confidence -= negativeConfidenceCorrection break } default: { break } } return modelResult } const require = createRequire(import.meta.url) export const detectCodeStringLanguage = async function* (codeString: string) { const { ModelOperations } = require("vscode-languagedetection") as typeof import("vscode-languagedetection") const modelOperations = new ModelOperations() const modelResults = await modelOperations.runModel(codeString) if (modelResults.length === 0) { logger.debug("no model results", codeString) return } const firstModelResult = adjustLanguageConfidence(modelResults[0]!) if (firstModelResult.confidence < expectedRelativeConfidence) { logger.debug("first model result confidence is less than expected relative confidence") return } const possibleLanguages: ModelResult[] = [firstModelResult] for (let current of modelResults) { if (current === firstModelResult) { continue } current = adjustLanguageConfidence(current) const currentHighest = possibleLanguages.at(-1) if (!currentHighest) { logger.debug("no current highest") continue } if (currentHighest.confidence - current.confidence >= expectedRelativeConfidence) { while (possibleLanguages.length > 0) { yield possibleLanguages.shift()! } if (current.confidence > expectedRelativeConfidence) { possibleLanguages.push(current) continue } return } else { if (current.confidence > expectedRelativeConfidence) { possibleLanguages.push(current) continue } return } } return possibleLanguages } ================================================ FILE: apps/desktop/layer/main/src/shims/utf-8-validate.cjs ================================================ "use strict" const { isUtf8 } = require("node:buffer") module.exports = function isValidUTF8(buffer) { if (typeof isUtf8 === "function") { return isUtf8(buffer) } try { new TextDecoder("utf-8", { fatal: true }).decode(buffer) return true } catch { return false } } ================================================ FILE: apps/desktop/layer/main/src/updater/api.ts ================================================ import type { DistributionStatusPayload, GetLatestReleaseQuery, LatestReleasePayload, } from "@follow-app/client-sdk" import { apiClient } from "~/lib/api-client" let cachedLatestRelease: LatestReleasePayload | null = null export const getUpdateInfo = async ( query: GetLatestReleaseQuery = {}, ): Promise<LatestReleasePayload> => { const response = await apiClient.updates.getLatestRelease(query) cachedLatestRelease = response.data return cachedLatestRelease } export const getDistributionUpdateInfo = async (): Promise<DistributionStatusPayload | null> => { const distribution = process.mas ? "mas" : process.windowsStore ? "mss" : undefined if (!distribution) { return null } const response = await apiClient.updates.getDistributionStatus({ distribution }) return response.data } ================================================ FILE: apps/desktop/layer/main/src/updater/configs.ts ================================================ import { DEV, MICROSOFT_STORE_BUILD, MODE, ModeEnum } from "@follow/shared/constants" const isStoreDistribution = Boolean(process.mas || MICROSOFT_STORE_BUILD) export const appUpdaterConfig = { // Disable renderer hot update will trigger app update when available enableRenderHotUpdate: !DEV && MODE !== ModeEnum.staging, enableCoreUpdate: !isStoreDistribution, // Disable app update will also disable renderer hot update and core update enableAppUpdate: true, enableDistributionStoreUpdate: isStoreDistribution, app: { autoCheckUpdate: true, autoDownloadUpdate: true, checkUpdateInterval: 15 * 60 * 1000, }, } ================================================ FILE: apps/desktop/layer/main/src/updater/follow-update-provider.ts ================================================ import { URL } from "node:url" import type { AppUpdate, LatestReleasePayload, PlatformUpdate, PlatformUpdateFile, } from "@follow-app/client-sdk" import type { UpdateFileInfo, UpdateInfo } from "builder-util-runtime" import { newError } from "builder-util-runtime" import type { AppUpdater } from "electron-updater" import type { ProviderRuntimeOptions } from "electron-updater/out/providers/Provider" import { Provider } from "electron-updater/out/providers/Provider" import type { ResolvedUpdateFileInfo } from "electron-updater/out/types" import { logger } from "../logger" import { getUpdateInfo } from "./api" interface FollowProviderOptions { provider: "custom" } type FollowProviderContext = { payload: LatestReleasePayload platform: PlatformUpdate } export class FollowUpdateProvider extends Provider<UpdateInfo> { private static context: FollowProviderContext | null = null static setContext(context: FollowProviderContext) { FollowUpdateProvider.context = context } static clearContext() { FollowUpdateProvider.context = null } static getContext() { return FollowUpdateProvider.context } constructor( _options: FollowProviderOptions, _updater: AppUpdater, runtimeOptions: ProviderRuntimeOptions, ) { super(runtimeOptions) } async getLatestVersion(): Promise<UpdateInfo> { const context = await this.ensureContext() return this.buildUpdateInfo(context) } resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> { return updateInfo.files.map((file) => ({ info: file, url: new URL(file.url), })) } private buildUpdateInfo(context: FollowProviderContext): UpdateInfo { const { payload, platform } = context const files = this.mapFiles(platform.files) if (files.length === 0) { throw newError( `No downloadable files found for platform ${platform.platform}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", ) } const primaryFile = files[0] if (!primaryFile) { throw newError( `Platform ${platform.platform} provides no downloadable file`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", ) } let primaryPath = primaryFile.url const primaryUrl = this.safeParseUrl(primaryFile.url) if (primaryUrl) { const filename = primaryUrl.pathname.split("/").pop() if (filename) { primaryPath = filename } } const { release } = payload return { version: platform.version, files, path: primaryPath, sha512: primaryFile.sha512, releaseName: release.name, releaseNotes: release.body, releaseDate: platform.releaseDate || release.publishedAt || new Date().toISOString(), } } private mapFiles(files: PlatformUpdate["files"]): UpdateFileInfo[] { if (!files) return [] return files .map((file) => this.mapFile(file)) .filter((file): file is UpdateFileInfo => file !== null) } private mapFile(file: PlatformUpdateFile): UpdateFileInfo | null { if (!file.downloadUrl || !file.sha512) { logger.warn("Skip platform file without downloadUrl or sha512", file) return null } const mapped: UpdateFileInfo = { url: file.downloadUrl, sha512: file.sha512, } if (typeof file.size === "number") { mapped.size = file.size } return mapped } private safeParseUrl(value: string): URL | null { try { return new URL(value) } catch (error) { logger.debug?.("Unable to parse update file URL", error) return null } } private async ensureContext(): Promise<FollowProviderContext> { const context = FollowUpdateProvider.getContext() if (context) return context const fetched = await this.fetchContext() FollowUpdateProvider.setContext(fetched) return fetched } private async fetchContext(): Promise<FollowProviderContext> { const payload = await getUpdateInfo({}) const { decision } = payload if (!decision || decision.type !== "app" || !decision.app) { throw newError( "No app update metadata available from provider", "ERR_UPDATER_NO_PUBLISHED_VERSIONS", ) } const platform = this.pickPlatform(decision.app) if (!platform) { throw newError( `No matching platform update for ${process.platform}/${process.arch}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", ) } return { payload, platform } } private pickPlatform(appDecision: AppUpdate): PlatformUpdate | null { const platforms = appDecision.platforms ?? [] const selected = appDecision.selectedPlatform if (selected) { return selected } const candidates = this.resolvePlatformCandidates() const matched = platforms.find((platform) => candidates.includes(platform.platform.toLowerCase()), ) return matched ?? platforms[0] ?? null } private resolvePlatformCandidates(): string[] { const base = new Set<string>() base.add(process.platform) base.add(`${process.platform}-${process.arch}`) base.add(process.arch) if (process.platform === "darwin") { base.add("mac") base.add("macos") } if (process.platform === "win32") { base.add("windows") base.add("win") } return Array.from(base).map((value) => value.toLowerCase()) } } ================================================ FILE: apps/desktop/layer/main/src/updater/hot-updater.ts ================================================ import { existsSync, readFileSync } from "node:fs" import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises" import os from "node:os" import { callWindowExpose } from "@follow/shared/bridge" import type { LatestReleasePayload, RendererUpdate } from "@follow-app/client-sdk" import { mainHash, version as appVersion } from "@pkg" import log from "electron-log" import { dump, load } from "js-yaml" import path from "pathe" import { x } from "tar" import { HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app" import { downloadFileWithProgress } from "~/lib/download" import { WindowManager } from "~/manager/window" import { appUpdaterConfig } from "./configs" declare const GIT_COMMIT_HASH: string | undefined export type RendererManifest = RendererUpdate & { downloadUrl: string downloadedAt?: string } export enum RendererEligibilityStatus { NoManifest, RequiresFullAppUpdate, AlreadyCurrent, Eligible, } export interface RendererEligibilityResult { status: RendererEligibilityStatus manifest?: RendererManifest reason?: string } class RendererHotUpdater { private readonly logger = log.scope("updater:renderer") private readonly tempDir = path.resolve(os.tmpdir(), "follow-render-update") private readonly manifestPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml") extractManifest(payload: LatestReleasePayload | null): RendererManifest | null { if (!payload) return null const { decision } = payload if (!decision || decision.type !== "renderer") { return null } return this.toManifest(decision.renderer) } extractManifestFromRendererUpdate(renderer: RendererUpdate | null): RendererManifest | null { return this.toManifest(renderer) } evaluateManifest(manifest: RendererManifest | null): RendererEligibilityResult { if (!manifest) { return { status: RendererEligibilityStatus.NoManifest } } if (manifest.mainHash && manifest.mainHash !== mainHash) { return { status: RendererEligibilityStatus.RequiresFullAppUpdate, manifest, reason: `Renderer payload requires main hash ${manifest.mainHash}, current main hash is ${mainHash}`, } } if (manifest.version === appVersion) { return { status: RendererEligibilityStatus.AlreadyCurrent, reason: "Renderer version matches current app version", } } if (manifest.commit && GIT_COMMIT_HASH && manifest.commit === GIT_COMMIT_HASH) { return { status: RendererEligibilityStatus.AlreadyCurrent, reason: "Renderer commit matches current main commit", } } const installedManifest = this.getCurrentManifest() if (installedManifest) { if (installedManifest.version === manifest.version) { return { status: RendererEligibilityStatus.AlreadyCurrent, reason: "Installed renderer manifest already at target version", } } if ( installedManifest.commit && manifest.commit && installedManifest.commit === manifest.commit ) { return { status: RendererEligibilityStatus.AlreadyCurrent, reason: "Installed renderer manifest commit matches target commit", } } } return { status: RendererEligibilityStatus.Eligible, manifest, } } private toManifest(renderer: RendererUpdate | null): RendererManifest | null { if (!renderer) { this.logger.debug("Renderer decision payload missing renderer field") return null } if (!renderer.downloadUrl) { this.logger.warn("Renderer decision missing downloadUrl, skip renderer hot update") return null } if (!renderer.filename) { this.logger.warn("Renderer decision missing filename, skip renderer hot update") return null } if (!renderer.hash) { this.logger.warn("Renderer decision missing hash, skip renderer hot update") return null } return { ...renderer, downloadUrl: renderer.downloadUrl, } } async applyManifest(manifest: RendererManifest): Promise<void> { if (!appUpdaterConfig.enableRenderHotUpdate) { this.logger.info("Renderer hot update skipped because it is disabled in config") return } const archivePath = await this.downloadArchive(manifest) await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true }) this.logger.info(`Extracting renderer bundle to ${HOTUPDATE_RENDER_ENTRY_DIR}`) await x({ f: archivePath, cwd: HOTUPDATE_RENDER_ENTRY_DIR, }) const extractedDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer") const targetDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version) const extractedStats = await stat(extractedDir).catch(() => null) if (!extractedStats) { throw new Error(`Extracted renderer directory not found at ${extractedDir}`) } await rm(targetDir, { recursive: true, force: true }) await rename(extractedDir, targetDir) await this.writeManifest({ ...manifest, downloadedAt: new Date().toISOString() }) try { await rm(archivePath, { force: true }) } catch (error) { this.logger.warn("Failed to clean renderer archive", error) } this.logger.info(`Renderer hot update applied successfully: ${manifest.version}`) const mainWindow = WindowManager.getMainWindow() if (mainWindow) { callWindowExpose(mainWindow).readyToUpdate() } } getCurrentManifest(): RendererManifest | null { if (!existsSync(this.manifestPath)) { return null } try { const content = readFileSync(this.manifestPath, "utf-8") const parsed = load(content) if (parsed && typeof parsed === "object") { return parsed as RendererManifest } } catch (error) { this.logger.warn("Failed to read renderer manifest from disk", error) } return null } async cleanup(): Promise<void> { const manifest = this.getCurrentManifest() if (!manifest) { await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true }) return } const keepDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version) let entries: string[] = [] try { entries = await readdir(HOTUPDATE_RENDER_ENTRY_DIR) } catch (error) { this.logger.warn("Failed to read renderer directory for cleanup", error) return } await Promise.all( entries.map(async (entryName) => { const entryPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, entryName) const entryStat = await stat(entryPath).catch(() => null) if (!entryStat?.isDirectory()) return if (entryPath === keepDir) return await rm(entryPath, { recursive: true, force: true }) }), ) } loadDynamicEntry() { if (!appUpdaterConfig.enableRenderHotUpdate) return const manifest = this.getCurrentManifest() if (!manifest) return if (manifest.mainHash && manifest.mainHash !== mainHash) return const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version) const entryFile = path.resolve(dir, "index.html") if (!existsSync(entryFile)) return return entryFile } private async downloadArchive(manifest: RendererManifest) { const archivePath = path.resolve(this.tempDir, manifest.filename) this.logger.info( `Downloading renderer bundle ${manifest.filename} from ${manifest.downloadUrl}`, ) const success = await downloadFileWithProgress({ url: manifest.downloadUrl, outputPath: archivePath, expectedHash: manifest.hash, onLog: (message) => this.logger.info(message), }) if (!success) { throw new Error("Failed to download renderer bundle") } return archivePath } private async writeManifest(manifest: RendererManifest) { await writeFile(this.manifestPath, dump(manifest), "utf-8") } } export const rendererUpdater = new RendererHotUpdater() export const getCurrentRendererManifest = () => rendererUpdater.getCurrentManifest() export const cleanupOldRenderer = async () => { await rendererUpdater.cleanup() } export const cleanupOldRender = cleanupOldRenderer export const loadDynamicRenderEntry = () => rendererUpdater.loadDynamicEntry() ================================================ FILE: apps/desktop/layer/main/src/updater/index.ts ================================================ import { fileURLToPath } from "node:url" import { callWindowExpose } from "@follow/shared/bridge" import { DEV } from "@follow/shared/constants" import type { DistributionStatusPayload, LatestReleasePayload, PlatformUpdate, RendererUpdate, } from "@follow-app/client-sdk" import { mainHash, version as appVersion } from "@pkg" import log from "electron-log" import type { AppUpdater } from "electron-updater" import { autoUpdater as defaultAutoUpdater } from "electron-updater" import { join } from "pathe" import { gt, valid as isValidSemver } from "semver" import { WindowManager } from "~/manager/window" import type { RendererManifest } from "~/updater/hot-updater" import { RendererEligibilityStatus, rendererUpdater } from "~/updater/hot-updater" import { channel, isWindows } from "../env" import { getDistributionUpdateInfo, getUpdateInfo } from "./api" import { appUpdaterConfig } from "./configs" import { FollowUpdateProvider } from "./follow-update-provider" import { WindowsUpdater } from "./windows-updater" const logger = log.scope("app-updater") type UpdateCheckOptions = { refresh?: boolean } type UpdateCheckResult = { hasUpdate: boolean error?: string } class FollowUpdater { private readonly disabled: boolean private checkingUpdate = false private downloadingUpdate = false private pollingTimer: NodeJS.Timeout | null = null constructor( private readonly autoUpdater: AppUpdater, private readonly renderer = rendererUpdater, ) { this.disabled = !appUpdaterConfig.enableAppUpdate } register() { if (this.disabled) { logger.info("App auto-update disabled; updater not registered") return } this.autoUpdater.autoDownload = false this.autoUpdater.allowPrerelease = channel !== "stable" this.autoUpdater.autoInstallOnAppQuit = true this.autoUpdater.autoRunAppAfterInstall = true this.autoUpdater.forceDevUpdateConfig = DEV if (import.meta.env.DEV) { const __dirname = fileURLToPath(new URL(".", import.meta.url)) this.autoUpdater.updateConfigPath = join(__dirname, "../../dev-only/dev-app-update.yml") } this.autoUpdater.setFeedURL({ provider: "custom", updateProvider: FollowUpdateProvider, }) this.registerAutoUpdaterEvents() if (appUpdaterConfig.app.autoCheckUpdate) { logger.info("Initial update check, mainHash:", mainHash) void this.checkForUpdates().catch((error) => logger.error("Initial update check failed", error), ) } if (this.pollingTimer) { clearInterval(this.pollingTimer) } const updatePollingHandler = async () => { if (!appUpdaterConfig.app.autoCheckUpdate) { return } void this.checkForUpdates().catch((error) => { logger.error("Scheduled update check failed", error) }) } updatePollingHandler() this.pollingTimer = setInterval(updatePollingHandler, appUpdaterConfig.app.checkUpdateInterval) } async checkForUpdates(options: UpdateCheckOptions = {}): Promise<UpdateCheckResult> { if (this.disabled) { return { hasUpdate: false } } if (this.checkingUpdate) { logger.info("Update check already in progress, skipping") return { hasUpdate: false } } this.checkingUpdate = true try { if (appUpdaterConfig.enableDistributionStoreUpdate) { logger.info("Distribution store update enabled, checking for distribution update") return this.handleDistributionAppDecision() } const payload = await getUpdateInfo(options.refresh ? { refresh: true } : {}) return this.handleDirectAppDecision(payload) } catch (error) { logger.error("Failed to check for updates", error) return { hasUpdate: false, error: error instanceof Error ? error.message : "Unknown error" } } finally { this.checkingUpdate = false } } async handleDirectAppDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> { const { decision } = payload if (!decision || decision.type === "none") { logger.info("Update decision: none") return { hasUpdate: false } } if (decision.type === "renderer") { logger.info("Update decision: renderer") return await this.handleRendererDecision(payload) } if (decision.type === "app") { logger.info("Update decision: app") return await this.handleAppDecision(payload) } logger.warn("Unknown update decision type", { type: decision.type }) return { hasUpdate: false } } async downloadAppUpdate(): Promise<void> { if (this.disabled || this.downloadingUpdate) { return } this.downloadingUpdate = true try { await this.autoUpdater.downloadUpdate() logger.info("App update download requested") } catch (error) { this.downloadingUpdate = false logger.error("Failed to download app update", error) throw error } } quitAndInstall() { const mainWindow = WindowManager.getMainWindow() logger.info("Quit and install triggered", { windowId: mainWindow?.id }) WindowManager.destroyMainWindow() setTimeout(() => { logger.info("Main window closed, quitting to install update") this.autoUpdater.quitAndInstall() }, 1000) } private resolvePlatformCandidates() { const base = new Set<string>() base.add(process.platform) base.add(`${process.platform}-${process.arch}`) base.add(process.arch) if (process.platform === "darwin") { base.add("mac") base.add("macos") } if (process.platform === "win32") { base.add("windows") base.add("win") } return Array.from(base).map((value) => value.toLowerCase()) } private pickPlatformUpdate( platforms: PlatformUpdate[] | null | undefined, selected?: PlatformUpdate | null, ): PlatformUpdate | null { if (!platforms || platforms.length === 0) { return null } if (selected) { return selected } const candidates = this.resolvePlatformCandidates() const matched = platforms.find((platform) => candidates.includes(platform.platform.toLowerCase()), ) return matched ?? platforms[0] ?? null } private async handleAppDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> { const appDecision = payload.decision.app if (!appUpdaterConfig.enableCoreUpdate) { logger.info("Core app update disabled by configuration") return { hasUpdate: false } } if (!appDecision) { logger.warn("App update decision missing app payload") return { hasUpdate: false, error: "App update metadata unavailable" } } const platformUpdate = this.pickPlatformUpdate( appDecision.platforms, appDecision.selectedPlatform, ) if (!platformUpdate) { logger.warn("No matching platform update found", { platform: process.platform, arch: process.arch, }) return { hasUpdate: false, error: "No installer available for this platform" } } FollowUpdateProvider.setContext({ payload, platform: platformUpdate }) logger.info("FollowUpdateProvider context set", { platform: platformUpdate.platform }) try { await this.autoUpdater.checkForUpdates() } catch (error) { logger.warn( "autoUpdater.checkForUpdates failed after preparing FollowUpdateProvider context", error, ) return { hasUpdate: false, error: error instanceof Error ? error.message : "Failed to check app update", } } finally { FollowUpdateProvider.clearContext() } return { hasUpdate: true } } private async handleDistributionAppDecision(): Promise<UpdateCheckResult> { try { if (!appUpdaterConfig.enableDistributionStoreUpdate) { return { hasUpdate: false } } const info = await getDistributionUpdateInfo() if (!info) { logger.info( "Distribution update info unavailable for current build, falling back to direct app decision", ) const payload = await getUpdateInfo() return this.handleDirectAppDecision(payload) } const rendererResult = await this.tryDistributionRendererUpdate(info.rendererUpdate) if (rendererResult) { return rendererResult } if (!this.shouldPromptDistributionStoreUpdate(info)) { logger.info("Distribution update does not require store action") return { hasUpdate: false } } logger.info("Distribution store update required") return await this.notifyDistributionUpdate(info) } catch (error) { logger.error("Failed to handle distribution app update", error) return { hasUpdate: false, error: error instanceof Error ? error.message : "Failed to handle distribution update", } } } private async tryDistributionRendererUpdate( renderer: RendererUpdate | null, ): Promise<UpdateCheckResult | null> { if (!renderer) { return null } if (!appUpdaterConfig.enableRenderHotUpdate) { logger.info("Renderer hot update disabled for distribution build") return null } const manifest = this.renderer.extractManifestFromRendererUpdate(renderer) if (!manifest) { logger.warn("Distribution renderer update missing manifest") return null } const eligibility = this.renderer.evaluateManifest(manifest) switch (eligibility.status) { case RendererEligibilityStatus.NoManifest: { if (eligibility.reason) { logger.warn("Distribution renderer update missing manifest data", { reason: eligibility.reason, }) } return null } case RendererEligibilityStatus.AlreadyCurrent: { if (eligibility.reason) { logger.info(eligibility.reason) } return { hasUpdate: false } } case RendererEligibilityStatus.RequiresFullAppUpdate: { logger.info( eligibility.reason ?? "Renderer payload requires main process update, delegating to distribution store flow", ) return null } case RendererEligibilityStatus.Eligible: { const manifestToApply = eligibility.manifest as RendererManifest | undefined if (!manifestToApply) { logger.warn("Distribution renderer update missing manifest payload") return null } try { await this.renderer.applyManifest(manifestToApply) return { hasUpdate: true } } catch (error) { logger.error("Renderer hot update failed for distribution build", error) return { hasUpdate: false, error: error instanceof Error ? error.message : "Renderer hot update failed", } } } default: { return null } } } private shouldPromptDistributionStoreUpdate(info: DistributionStatusPayload): boolean { if (!info.storeUrl) { logger.info("Distribution store update skipped: missing store URL", { distribution: info.distribution, }) return false } const { storeVersion } = info const currentVersion = appVersion if (!storeVersion) { logger.info("Distribution store update skipped: missing store version") return false } if (!currentVersion) { return true } const storeValid = isValidSemver(storeVersion) const currentValid = isValidSemver(currentVersion) if (storeValid && currentValid) { const needsUpdate = gt(storeVersion, currentVersion) if (!needsUpdate) { logger.info("Distribution store version matches current version", { storeVersion, currentVersion, }) } return needsUpdate } if (storeVersion === currentVersion) { logger.info("Distribution store version identical to current version", { storeVersion, currentVersion, }) return false } return true } private async notifyDistributionUpdate( info: DistributionStatusPayload, ): Promise<UpdateCheckResult> { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) { logger.warn("Main window unavailable when notifying distribution update") return { hasUpdate: true } } if (!info.storeUrl) { logger.warn("Distribution update missing store URL", { distribution: info.distribution, }) return { hasUpdate: false } } await callWindowExpose(mainWindow).distributionUpdateAvailable({ distribution: info.distribution, storeUrl: info.storeUrl, storeVersion: info.storeVersion ?? null, currentVersion: appVersion, }) return { hasUpdate: true } } private async handleRendererDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> { if (!appUpdaterConfig.enableRenderHotUpdate) { logger.info("Renderer hot update disabled; falling back to app decision if present") if (payload.decision.app) { return this.handleAppDecision(payload) } return { hasUpdate: false } } const manifest = this.renderer.extractManifest(payload) const eligibility = this.renderer.evaluateManifest(manifest) switch (eligibility.status) { case RendererEligibilityStatus.NoManifest: { return { hasUpdate: false, error: eligibility.reason } } case RendererEligibilityStatus.AlreadyCurrent: { if (eligibility.reason) { logger.info(eligibility.reason) } return { hasUpdate: false } } case RendererEligibilityStatus.RequiresFullAppUpdate: { logger.info( eligibility.reason, "Renderer payload requires main process update, delegating to app updater", ) if (payload.decision.app) { return this.handleAppDecision(payload) } logger.warn("Renderer update requested full app upgrade but no app payload provided") return { hasUpdate: false, error: "Renderer update requires full app upgrade" } } case RendererEligibilityStatus.Eligible: { const manifestToApply = eligibility.manifest as RendererManifest | undefined if (!manifestToApply) { return { hasUpdate: false } } try { await this.renderer.applyManifest(manifestToApply) return { hasUpdate: true } } catch (error) { logger.error("Renderer hot update failed", error) return { hasUpdate: false, error: error instanceof Error ? error.message : "Renderer hot update failed", } } } default: { return { hasUpdate: false } } } } private registerAutoUpdaterEvents() { this.autoUpdater.on("checking-for-update", () => { logger.info("autoUpdater: checking for update") }) this.autoUpdater.on("update-available", (info) => { logger.info("autoUpdater: update available", info) if (appUpdaterConfig.app.autoDownloadUpdate && appUpdaterConfig.enableCoreUpdate) { void this.downloadAppUpdate().catch((error) => logger.error("Automatic download failed", error), ) } }) this.autoUpdater.on("update-not-available", (info) => { logger.info("autoUpdater: update not available", info) }) this.autoUpdater.on("download-progress", (progress) => { logger.info(`autoUpdater: download progress ${progress.percent.toFixed(2)}%`) }) this.autoUpdater.on("update-downloaded", (ev) => { this.downloadingUpdate = false logger.info("autoUpdater: update downloaded", ev.downloadedFile, ev.version) const mainWindow = WindowManager.getMainWindow() if (!mainWindow) return callWindowExpose(mainWindow).updateDownloaded() }) this.autoUpdater.on("error", (error) => { logger.error("autoUpdater: error", error) }) } } const autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater const followUpdater = new FollowUpdater(autoUpdater) export const registerUpdater = () => { followUpdater.register() } export const checkForAppUpdates = (options: UpdateCheckOptions = {}) => followUpdater.checkForUpdates(options) export const quitAndInstall = () => followUpdater.quitAndInstall() ================================================ FILE: apps/desktop/layer/main/src/updater/logger.ts ================================================ import log from "electron-log" /** * Logger for updater module with scoped prefix * All logs are prefixed with [Updater] for easy identification */ export const updaterLogger = log.scope("updater") /** * Logger specifically for GitHub provider operations */ export const githubProviderLogger = log.scope("updater:github") /** * Helper to log object properties in a formatted way */ export function logObject(logger: typeof updaterLogger, prefix: string, obj: Record<string, any>) { logger.info(`${prefix}:`) for (const [key, value] of Object.entries(obj)) { logger.info(` ${key}: ${value}`) } } ================================================ FILE: apps/desktop/layer/main/src/updater/windows-updater.ts ================================================ import { app } from "electron" import { NsisUpdater } from "electron-updater" import { DownloadedUpdateHelper } from "electron-updater/out/DownloadedUpdateHelper" export class WindowsUpdater extends NsisUpdater { protected override downloadedUpdateHelper: DownloadedUpdateHelper = new DownloadedUpdateHelper( app.getPath("sessionData"), ) } ================================================ FILE: apps/desktop/layer/main/tsconfig.json ================================================ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "compilerOptions": { "composite": true, "outDir": "dist", "types": ["electron-vite/node", "@follow/types/vite", "@follow/types/global"], "moduleResolution": "Bundler", "noImplicitReturns": false, "noUnusedLocals": false, "noUnusedParameters": false, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "experimentalDecorators": true, "baseUrl": ".", "paths": { "@pkg": ["../../package.json"], "@locales/*": ["../../../../locales/*"], "~/*": ["./src/*"] } } } ================================================ FILE: apps/desktop/layer/main/vitest.config.ts ================================================ import { fileURLToPath } from "node:url" import tsconfigPath from "vite-tsconfig-paths" import { defineProject } from "vitest/config" const __dirname = fileURLToPath(new URL(".", import.meta.url)) export default defineProject({ root: "./", test: { globals: true, environment: "node", }, plugins: [ tsconfigPath({ projects: ["./tsconfig.json"], }), ], }) ================================================ FILE: apps/desktop/layer/renderer/debug_proxy.html ================================================ <!doctype html> <html lang="en"> <head> <title>Debug Proxy ================================================ FILE: apps/desktop/layer/renderer/global.d.ts ================================================ import type { ElectronAPI } from "@electron-toolkit/preload" declare global { interface Window { electron?: ElectronAPI api?: { canWindowBlur: boolean; isWindowsStore: boolean } platform: NodeJS.Platform } export const APP_NAME = "Folo" } declare module "virtual:pwa-register/react" { import type { Dispatch, SetStateAction } from "react" import type { RegisterSWOptions } from "vite-plugin-pwa/types" export function useRegisterSW(options?: RegisterSWOptions): { needRefresh: [boolean, Dispatch>] offlineReady: [boolean, Dispatch>] updateServiceWorker: (reloadPage?: boolean) => Promise } } export {} export { type RegisterSWOptions } from "vite-plugin-pwa/types" ================================================ FILE: apps/desktop/layer/renderer/index.html ================================================ Folo - AI Reader | Follow Everything
================================================ FILE: apps/desktop/layer/renderer/package.json ================================================ { "name": "@follow/web", "type": "module", "private": true, "main": "./dist/main/index.js", "scripts": { "build:web": "cd ../.. && pnpm build:web", "db:generate": "pnpm --filter @follow/database run generate", "dev": "cd ../.. && pnpm dev:web", "dev:ssl": "cd ../.. && SSL=true pnpm dev:web", "generate-pwa-assets": "pwa-assets-generator public/icon.svg", "test": "vitest --typecheck", "typecheck": "tsc --noEmit" }, "dependencies": { "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@electron-toolkit/preload": "3.0.2", "@follow-app/client-sdk": "catalog:", "@follow/database": "workspace:*", "@follow/electron-main": "workspace:*", "@follow/shared": "workspace:*", "@follow/store": "workspace:*", "@follow/tracker": "workspace:*", "@fontsource/sn-pro": "5.2.6", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", "@lexical/markdown": "0.40.0", "@lexical/react": "0.40.0", "@number-flow/react": "0.5.11", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.8", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", "@shikijs/transformers": "3.22.0", "@splinetool/react-spline": "4.1.0", "@tanstack/query-sync-storage-persister": "5.90.22", "@tanstack/react-query": "5.90.21", "@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-persist-client": "5.90.22", "@tanstack/react-virtual": "3.13.18", "@toon-format/toon": "2.1.0", "@use-gesture/react": "10.3.1", "@welldone-software/why-did-you-render": "10.0.1", "@xyflow/react": "12.10.0", "@yornaath/batshit": "0.14.0", "ai": "6.0.85", "camelcase-keys": "10.0.2", "chrono-node": "2.9.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cookie-es": "2.0.0", "dayjs": "1.11.19", "dnum": "2.17.0", "electron-ipc-decorator": "0.2.0", "embla-carousel-react": "8.6.0", "embla-carousel-wheel-gestures": "8.1.0", "es-toolkit": "1.44.0", "firebase": "12.9.0", "foxact": "0.2.52", "franc-min": "6.2.0", "fuse.js": "7.1.0", "hast-util-to-jsx-runtime": "2.3.6", "hast-util-to-mdast": "10.1.2", "i18next": "25.8.6", "i18next-browser-languagedetector": "8.2.1", "idb-keyval": "6.2.2", "immer": "11.1.4", "jotai": "2.17.1", "lethargy": "1.0.9", "lexical": "0.40.0", "masonic": "4.1.0", "mdast-util-gfm-table": "2.0.0", "mdast-util-to-markdown": "2.1.2", "motion": "12.34.0", "nanoid": "5.1.6", "ofetch": "1.5.1", "plain-shiki": "0.3.2", "re-resizable": "6.11.2", "react": "19.0.0", "react-blurhash": "0.3.0", "react-dom": "19.0.0", "react-error-boundary": "6.1.0", "react-fast-compare": "3.2.2", "react-fast-marquee": "1.6.5", "react-google-recaptcha-v3": "1.11.0", "react-hook-form": "7.71.1", "react-hotkeys-hook": "5.2.4", "react-i18next": "16.5.4", "react-intersection-observer": "10.0.2", "react-ios-pwa-prompt": "2.0.6", "react-markdown": "10.1.0", "react-qr-code": "2.0.18", "react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1", "react-router": "7.13.0", "react-selecto": "1.26.3", "react-shadow": "20.6.0", "react-zoom-pan-pinch": "3.7.0", "rehype-raw": "7.0.0", "shiki": "3.22.0", "sonner": "2.0.7", "tinykeys": "3.0.0", "title-case": "4.3.2", "tldts": "7.0.23", "ufo": "1.6.3", "use-context-selector": "2.0.0", "use-sync-external-store": "1.6.0", "usehooks-ts": "3.1.1", "zod": "3.25.76", "zustand": "5.0.11" }, "devDependencies": { "@follow/atoms": "workspace:*", "@follow/components": "workspace:*", "@follow/constants": "workspace:*", "@follow/hooks": "workspace:*", "@follow/logger": "workspace:*", "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", "@folo-services/ai-tools": "catalog:", "@types/node": "25.2.3", "@vite-pwa/assets-generator": "1.0.2", "fake-indexeddb": "6.2.5", "happy-dom": "20.6.1", "react-scan": "0.4.3", "typescript": "catalog:" } } ================================================ FILE: apps/desktop/layer/renderer/pwa-assets.config.ts ================================================ import type { Preset } from "@vite-pwa/assets-generator/config" import { defineConfig } from "@vite-pwa/assets-generator/config" const minimal2023Preset: Preset = { transparent: { sizes: [64, 192, 512], favicons: [[48, "favicon.ico"]], padding: 0.05, // rgba(255, 92, 0, 1) resizeOptions: { fit: "contain", background: { r: 255, g: 92, b: 0, alpha: 1, }, }, }, maskable: { sizes: [512], padding: 0, resizeOptions: { fit: "contain", background: { r: 255, g: 92, b: 0, alpha: 1, }, }, }, apple: { sizes: [180], padding: 0, resizeOptions: { fit: "contain", background: { r: 255, g: 92, b: 0, alpha: 1, }, }, }, } export default defineConfig({ headLinkOptions: { preset: "2023", }, preset: minimal2023Preset, images: ["public/logo.svg"], }) ================================================ FILE: apps/desktop/layer/renderer/setup-file.ts ================================================ // @ts-nocheck import "fake-indexeddb/auto" import { enableMapSet } from "immer" globalThis.window = { location: new URL("https://example.com"), __dbIsReady: true, addEventListener: () => {}, get navigator() { return globalThis.navigator }, } if (!globalThis.navigator) { globalThis.navigator = { onLine: true, userAgent: "node", } } enableMapSet() ================================================ FILE: apps/desktop/layer/renderer/src/@types/constants.ts ================================================ // DONT EDIT THIS FILE MANUALLY const langs = ["en", "zh-CN", "zh-TW", "ja", "fr-FR"] as const export const currentSupportedLanguages = langs as readonly string[] export type RendererSupportedLanguages = (typeof langs)[number] export const dayjsLocaleImportMap = { en: ["en", () => import("dayjs/locale/en")], ["zh-CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")], ["ja"]: ["ja", () => import("dayjs/locale/ja")], ["zh-TW"]: ["zh-tw", () => import("dayjs/locale/zh-tw")], ["fr-FR"]: ["fr", () => import("dayjs/locale/fr")], } export const ns = ["common", "lang", "errors", "app", "settings", "shortcuts", "ai"] as const export const defaultNS = "app" as const ================================================ FILE: apps/desktop/layer/renderer/src/@types/default-resource.electron.ts ================================================ // DONT EDIT THIS FILE MANUALLY import ai_en from "@locales/ai/en.json" import ai_frFR from "@locales/ai/fr-FR.json" import ai_ja from "@locales/ai/ja.json" import en from "@locales/app/en.json" import app_frFR from "@locales/app/fr-FR.json" import app_ja from "@locales/app/ja.json" import app_zhCN from "@locales/app/zh-CN.json" import app_zhTW from "@locales/app/zh-TW.json" import common_en from "@locales/common/en.json" import common_frFR from "@locales/common/fr-FR.json" import common_ja from "@locales/common/ja.json" import common_zhCN from "@locales/common/zh-CN.json" import common_zhTW from "@locales/common/zh-TW.json" import errors_en from "@locales/errors/en.json" import errors_frFR from "@locales/errors/fr-FR.json" import errors_ja from "@locales/errors/ja.json" import errors_zhCN from "@locales/errors/zh-CN.json" import errors_zhTW from "@locales/errors/zh-TW.json" import lang_en from "@locales/lang/en.json" import lang_frFR from "@locales/lang/fr-FR.json" import lang_ja from "@locales/lang/ja.json" import lang_zhCN from "@locales/lang/zh-CN.json" import lang_zhTW from "@locales/lang/zh-TW.json" import settings_en from "@locales/settings/en.json" import settings_frFR from "@locales/settings/fr-FR.json" import settings_ja from "@locales/settings/ja.json" import settings_zhCN from "@locales/settings/zh-CN.json" import settings_zhTW from "@locales/settings/zh-TW.json" import shortcuts_en from "@locales/shortcuts/en.json" import shortcuts_frFR from "@locales/shortcuts/fr-FR.json" import shortcuts_ja from "@locales/shortcuts/ja.json" import shortcuts_zhCN from "@locales/shortcuts/zh-CN.json" import shortcuts_zhTW from "@locales/shortcuts/zh-TW.json" import type { ns, RendererSupportedLanguages } from "./constants" /** * This file is the language resource that is loaded in full when the app is initialized. * In electron, we can load all the language resources synchronously. */ export const defaultResources = { en: { app: en, lang: lang_en, common: common_en, settings: settings_en, shortcuts: shortcuts_en, errors: errors_en, ai: ai_en, }, "zh-CN": { app: app_zhCN, lang: lang_zhCN, common: common_zhCN, settings: settings_zhCN, shortcuts: shortcuts_zhCN, errors: errors_zhCN, ai: ai_en, // Fallback to English until Chinese translation is available }, ja: { app: app_ja, lang: lang_ja, common: common_ja, settings: settings_ja, shortcuts: shortcuts_ja, errors: errors_ja, ai: ai_ja, }, "zh-TW": { app: app_zhTW, lang: lang_zhTW, common: common_zhTW, settings: settings_zhTW, shortcuts: shortcuts_zhTW, errors: errors_zhTW, ai: ai_en, // Fallback to English until Traditional Chinese translation is available }, "fr-FR": { app: app_frFR, lang: lang_frFR, common: common_frFR, settings: settings_frFR, shortcuts: shortcuts_frFR, errors: errors_frFR, ai: ai_frFR, }, } satisfies Record< RendererSupportedLanguages, Partial>> > ================================================ FILE: apps/desktop/layer/renderer/src/@types/default-resource.ts ================================================ // DONT EDIT THIS FILE MANUALLY import ai_en from "@locales/ai/en.json" import en from "@locales/app/en.json" import common_en from "@locales/common/en.json" import common_frFR from "@locales/common/fr-FR.json" import common_ja from "@locales/common/ja.json" import common_zhCN from "@locales/common/zh-CN.json" import common_zhTW from "@locales/common/zh-TW.json" import errors_en from "@locales/errors/en.json" import lang_en from "@locales/lang/en.json" import lang_frFR from "@locales/lang/fr-FR.json" import lang_ja from "@locales/lang/ja.json" import lang_zhCN from "@locales/lang/zh-CN.json" import lang_zhTW from "@locales/lang/zh-TW.json" import settings_en from "@locales/settings/en.json" import shortcuts_en from "@locales/shortcuts/en.json" import type { ns, RendererSupportedLanguages } from "./constants" /** * This file is the language resource that is loaded in full when the app is initialized. * When switching languages, the app will automatically download the required language resources, * we will not load all the language resources to minimize the first screen loading time of the app. * Generally, we only load english resources synchronously by default. * In addition, we attach common resources for other languages, and the size of the common resources must be controlled. */ export const defaultResources = { en: { app: en, lang: lang_en, common: common_en, settings: settings_en, shortcuts: shortcuts_en, errors: errors_en, ai: ai_en, }, "zh-CN": { lang: lang_zhCN, common: common_zhCN, }, ja: { lang: lang_ja, common: common_ja, }, "zh-TW": { lang: lang_zhTW, common: common_zhTW }, "fr-FR": { lang: lang_frFR, common: common_frFR }, } satisfies Record< RendererSupportedLanguages, Partial>> > ================================================ FILE: apps/desktop/layer/renderer/src/@types/i18next.d.ts ================================================ import type { defaultNS, ns } from "./constants" import type { defaultResources as resources } from "./default-resource" declare module "i18next" { interface CustomTypeOptions { // ns: ["app", "common", "external", "lang", "settings", "shortcuts"] ns: typeof ns resources: (typeof resources)["en"] defaultNS: typeof defaultNS // if you see an error like: "Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz" // set returnNull to false (and also in the i18next init options) // returnNull: false; } } ================================================ FILE: apps/desktop/layer/renderer/src/App.tsx ================================================ import { isMobile } from "@follow/components/hooks/useMobile.js" import { IN_ELECTRON } from "@follow/shared/constants" import { tracker } from "@follow/tracker" import { nextFrame } from "@follow/utils" import { cn, getOS } from "@follow/utils/utils" import { useEffect, useLayoutEffect, useRef } from "react" import { Outlet } from "react-router" import { useAppIsReady } from "./atoms/app" import { useUISettingKey } from "./atoms/settings/ui" import { applyAfterReadyCallbacks } from "./initialize/queue" import { removeAppSkeleton } from "./lib/app" import { ipcServices } from "./lib/client" import { appLog } from "./lib/log" import { Titlebar } from "./modules/app/Titlebar" import { RootProviders } from "./providers/root-providers" function App() { const windowsElectron = IN_ELECTRON && getOS() === "Windows" return ( {IN_ELECTRON && (
{windowsElectron && }
)}
) } const AppLayer = () => { const appIsReady = useAppIsReady() const onceReady = useRef(false) useLayoutEffect(() => { if (appIsReady && !onceReady.current) { onceReady.current = true ipcServices?.app.readyToShowMainWindow() nextFrame(removeAppSkeleton) } }, [appIsReady]) useEffect(() => { const doneTime = Math.trunc(performance.now()) tracker.uiRenderInit(doneTime) appLog("App is ready", `${doneTime}ms`) applyAfterReadyCallbacks() if (isMobile()) { const handler = (e: MouseEvent) => { e.preventDefault() } document.addEventListener("contextmenu", handler) return () => { document.removeEventListener("contextmenu", handler) } } }, [appIsReady]) return appIsReady ? : } const AppSkeleton = () => { const feedColWidth = useUISettingKey("feedColWidth") return (
) } export { App as Component } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/ai-summary.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" import { useGeneralSettingKey } from "./settings/general" export const [, , useShowAISummaryOnce, , getShowAISummaryOnce, setShowAISummaryOnce] = createAtomHooks(atom(false)) export const toggleShowAISummaryOnce = () => setShowAISummaryOnce((prev) => !prev) export const enableShowAISummaryOnce = () => setShowAISummaryOnce(true) export const disableShowAISummaryOnce = () => setShowAISummaryOnce(false) export const useShowAISummaryAuto = (settings?: boolean | null) => { return useGeneralSettingKey("summary") || !!settings } export const useShowAISummary = (settings?: boolean | null) => { const showAISummaryAuto = useShowAISummaryAuto(settings) const showAISummaryOnce = useShowAISummaryOnce() return showAISummaryAuto || showAISummaryOnce || !!settings } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/ai-translation.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" import { useGeneralSettingKey } from "./settings/general" // NOTE: We have three levels of settings can enable AI translation or Summary: // 1. General setting, which is the global settings for all entries. // 2. Action setting, which is defined in an action and applied to specific entries. // 3. Toolbar control, which is a temporary setting for the current entry. // // When general setting or action setting is enabled, we should hide the toolbar control, which can save some space. // // 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. export const [, , useShowAITranslationOnce, , getShowAITranslationOnce, setShowAITranslationOnce] = createAtomHooks(atom(false)) export const toggleShowAITranslationOnce = () => setShowAITranslationOnce((prev) => !prev) export const enableShowAITranslationOnce = () => setShowAITranslationOnce(true) export const disableShowAITranslationOnce = () => setShowAITranslationOnce(false) export const useShowAITranslationAuto = (settings?: boolean | null) => { return useGeneralSettingKey("translation") || !!settings } export const useShowAITranslation = (settings?: boolean | null) => { const showAITranslationAuto = useShowAITranslationAuto(settings) const showAITranslationOnce = useShowAITranslationOnce() return showAITranslationAuto || showAITranslationOnce } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/app.ts ================================================ import { WindowState } from "@follow/shared/bridge" import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" export const [, , useAppIsReady, , appIsReady, setAppIsReady] = createAtomHooks(atom(false)) export const [, , useAppMessagingToken, , appMessagingToken, setAppMessagingToken] = createAtomHooks(atom(null)) export const [, , useAppSearchOpen, , , setAppSearchOpen] = createAtomHooks(atom(false)) // For electron export const [, , useWindowState, , windowState, setWindowState] = createAtomHooks( atom(WindowState.NORMAL), ) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/context-menu.ts ================================================ import { IN_ELECTRON } from "@follow/shared/constants" import { getOS, transformShortcut } from "@follow/utils/utils" import { atom } from "jotai" import { useCallback } from "react" import { useRequireLogin } from "~/hooks/common/useRequireLogin" import { ipcServices } from "~/lib/client" import { createAtomHooks } from "~/lib/jotai" import type { ElectronMenuItem } from "~/lib/native-menu" import { showElectronContextMenu } from "~/lib/native-menu" // Atom type ContextMenuState = | { open: false } | { open: true position: { x: number; y: number } menuItems: FollowMenuItem[] // Just for abort callback // Also can be optimized by using the `atomWithListeners` abortController: AbortController } export const [contextMenuAtom, useContextMenuState, useContextMenuValue, useSetContextMenu] = createAtomHooks(atom({ open: false })) const useShowWebContextMenu = () => { const setContextMenu = useSetContextMenu() const showWebContextMenu = useCallback( async (menuItems: Array, e: MouseEvent | React.MouseEvent) => { const abortController = new AbortController() const resolvers = Promise.withResolvers() setContextMenu({ open: true, position: { x: e.clientX, y: e.clientY }, menuItems, abortController, }) abortController.signal.addEventListener("abort", () => { resolvers.resolve() }) return resolvers.promise }, [setContextMenu], ) return showWebContextMenu } // Menu export type FollowMenuItem = MenuItemText | MenuItemSeparator export type MenuItemInput = MenuItemText | MenuItemSeparator | NilValue function sortShortcutsString(shortcut: string) { const order = ["Shift", "Ctrl", "Meta", "Alt"] const nextShortcut = transformShortcut(shortcut) const arr = nextShortcut.split("+") const sortedModifiers = arr .filter((key) => order.includes(key)) .sort((a, b) => order.indexOf(a) - order.indexOf(b)) const otherKeys = arr.filter((key) => !order.includes(key)) return [...sortedModifiers, ...otherKeys].join("+") } function filterNullableMenuItems(items: MenuItemInput[]): FollowMenuItem[] { return items .filter((item) => item !== null && item !== undefined && item !== false && item !== "") .filter((item) => !item.hide) .map((item) => { if (item instanceof MenuItemSeparator) { return MENU_ITEM_SEPARATOR } if (item.submenu && item.submenu.length > 0) { return item.extend({ submenu: filterNullableMenuItems(item.submenu), }) } return item }) } // MenuItem must have at least one of label, role or type function transformMenuItemsForNative(nextItems: FollowMenuItem[]): ElectronMenuItem[] { return nextItems.map((item) => { if (item instanceof MenuItemSeparator) { return { type: "separator" } } return { type: typeof item.checked === "boolean" ? "checkbox" : undefined, label: item.label, click: item.click, enabled: (!item.disabled && item.click !== undefined) || (!!item.submenu && item.submenu.length > 0), accelerator: item.shortcut?.replace("$mod", "CmdOrCtrl"), checked: typeof item.checked === "boolean" ? item.checked : undefined, submenu: item.submenu.length > 0 ? transformMenuItemsForNative(filterNullableMenuItems(item.submenu)) : undefined, } }) } function withDebugMenu(menuItems: Array, e: MouseEvent | React.MouseEvent) { if (import.meta.env.DEV && e) { menuItems.push( MENU_ITEM_SEPARATOR, new MenuItemText({ label: "Inspect Element", click: () => { ipcServices?.debug.inspectElement({ x: e.pageX, y: e.pageY, }) }, }), ) } return menuItems } export enum MenuItemType { Separator, Action, } export const useShowContextMenu = () => { const showWebContextMenu = useShowWebContextMenu() const { withLoginGuard } = useRequireLogin() const guardMenuItems = useCallback( (items: FollowMenuItem[]): FollowMenuItem[] => items.map((item) => { if (item instanceof MenuItemSeparator) { return item } const nextSubmenu = item.submenu.length > 0 ? guardMenuItems(item.submenu) : item.submenu let nextItem = nextSubmenu !== item.submenu ? item.extend({ submenu: nextSubmenu }) : item if (item.requiresLogin) { nextItem = nextItem.extend({ click: withLoginGuard(nextItem.click), }) } return nextItem }), [withLoginGuard], ) const showContextMenu = useCallback( async (inputMenu: Array, e: MouseEvent | React.MouseEvent) => { const menuItems = guardMenuItems(filterNullableMenuItems(inputMenu)) // only show native menu on macOS electron, because in other platform, the native ui is not good if (IN_ELECTRON && getOS() === "macOS") { withDebugMenu(menuItems, e) await showElectronContextMenu(transformMenuItemsForNative(menuItems)) return } await showWebContextMenu(menuItems, e) }, [guardMenuItems, showWebContextMenu], ) return showContextMenu } export class MenuItemSeparator { readonly type = MenuItemType.Separator constructor(public hide = false) {} static default = new MenuItemSeparator() } const noop = () => void 0 export type BaseMenuItemTextConfig = { label: string click?: () => void /** only work in web app */ icon?: React.ReactNode shortcut?: string disabled?: boolean checked?: boolean supportMultipleSelection?: boolean requiresLogin?: boolean } export class BaseMenuItemText { readonly type = MenuItemType.Action private __sortedShortcut: string | null = null constructor(private configs: BaseMenuItemTextConfig) { this.__sortedShortcut = this.configs.shortcut ? sortShortcutsString(this.configs.shortcut) : null } public get label() { return this.configs.label } public get click() { return this.configs.click?.bind(this.configs) || noop } public get onClick() { return this.click } public get icon() { return this.configs.icon } public get shortcut() { return this.__sortedShortcut } public get disabled() { return this.configs.disabled || false } public get checked() { return this.configs.checked } public get supportMultipleSelection() { return this.configs.supportMultipleSelection } public get requiresLogin() { return this.configs.requiresLogin || false } } export type MenuItemTextConfig = Prettify< BaseMenuItemTextConfig & { hide?: boolean submenu?: MenuItemInput[] } > export class MenuItemText extends BaseMenuItemText { protected __submenu: FollowMenuItem[] constructor(protected config: MenuItemTextConfig) { super(config) this.__submenu = this.config.submenu ? filterNullableMenuItems(this.config.submenu) : [] } public get submenu() { return this.__submenu } public get hide() { return this.config.hide || false } extend(config: Partial) { return new MenuItemText({ ...this.config, ...config, }) } } export const MENU_ITEM_SEPARATOR = MenuItemSeparator.default ================================================ FILE: apps/desktop/layer/renderer/src/atoms/corner-player.ts ================================================ import { getStorageNS } from "@follow/utils/ns" import { atomWithStorage } from "jotai/utils" import { createAtomHooks } from "~/lib/jotai" type CornerPlayerAtomValue = { show: boolean type?: "audio" entryId?: string url?: string } const cornerPlayerInitialValue: CornerPlayerAtomValue = { show: false, } export const [ cornerPlayerAtom, useCornerPlayerAtom, useCornerPlayerAtomValue, useSetCornerPlayerAtom, getCornerPlayerAtomValue, setCornerPlayerAtomValue, ] = createAtomHooks( atomWithStorage(getStorageNS("corner-player"), cornerPlayerInitialValue, undefined, { getOnInit: true, }), ) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/debug-feature.ts ================================================ import { createAtomHooks } from "@follow/utils/jotai" import { getStorageNS } from "@follow/utils/ns" import { atomWithStorage } from "jotai/utils" // Shape: { __override?: boolean, [featureKey: string]: boolean } export const [ , , useDebugFeatureValue, useSetDebugFeatureValue, getDebugFeatureValue, setDebugFeatureValue, ] = createAtomHooks(atomWithStorage>(getStorageNS("debug-feature"), {})) export { useDebugFeatureValue as useDebugFeatures } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/dom.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" export const [, , useMainContainerElement, , getMainContainerElement, setMainContainerElement] = createAtomHooks(atom(null)) export const [, , useRootContainerElement, , getRootContainerElement, setRootContainerElement] = createAtomHooks(atom(null)) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/lang.ts ================================================ import { atom } from "jotai" export const langLoadingLockMapAtom = atom({} as Record) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/network.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" export enum NetworkStatus { ONLINE, OFFLINE, } export const [, , useNetworkStatus, , getNetworkStatus, setNetworkStatus] = createAtomHooks( atom(navigator.onLine ? NetworkStatus.ONLINE : NetworkStatus.OFFLINE), ) export const [, , useApiStatus, , getApiStatus, setApiStatus] = createAtomHooks( atom(NetworkStatus.ONLINE), ) export const subscribeNetworkStatus = () => { const handleOnline = () => setNetworkStatus(NetworkStatus.ONLINE) const handleOffline = () => setNetworkStatus(NetworkStatus.OFFLINE) window.addEventListener("online", handleOnline) window.addEventListener("offline", handleOffline) setNetworkStatus(navigator.onLine ? NetworkStatus.ONLINE : NetworkStatus.OFFLINE) return () => { window.removeEventListener("online", handleOnline) window.removeEventListener("offline", handleOffline) } } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/player.ts ================================================ import { getStorageNS } from "@follow/utils/ns" import { parseSafeUrl } from "@follow/utils/utils" import { noop } from "foxact/noop" import { atomWithStorage, createJSONStorage } from "jotai/utils" import type { SyncStorage } from "jotai/vanilla/utils/atomWithStorage" import { getRouteParams } from "~/hooks/biz/useRouteParams" import { createAtomHooks } from "~/lib/jotai" type PlayerAtomValue = { show: boolean type?: "audio" entryId?: string src?: string status?: "playing" | "paused" | "loading" duration?: number currentTime?: number isMute?: boolean volume?: number playbackRate?: number /** the listId from the route to indicate that the audio is triggered from a list */ listId?: string isStream?: boolean } const playerInitialValue: PlayerAtomValue = { show: false, volume: 0.8, duration: 0, playbackRate: 1, isStream: false, } const jsonStorage = createJSONStorage() let hydrationDone = false const patchedLocalStorage: SyncStorage = { setItem: jsonStorage.setItem, getItem: (key, initialValue) => { const value = jsonStorage.getItem(key, initialValue) if (value.isStream) { return playerInitialValue } if (value && !hydrationDone) { // patch status to `paused` when hydration value.status = "paused" value.isStream = false hydrationDone = true } return value }, removeItem: jsonStorage.removeItem, } export const [ , , useAudioPlayerAtomValue, useAudioSetPlayerAtom, getAudioPlayerAtomValue, setAudioPlayerAtomValue, useAudioPlayerAtomSelector, ] = createAtomHooks( atomWithStorage(getStorageNS("player"), playerInitialValue, patchedLocalStorage, { getOnInit: true, }), ) export const AudioPlayer = { audio: new Audio(), currentTimeTimer: null as ReturnType | null, __currentActionId: 0, get() { return getAudioPlayerAtomValue() }, mount(v: Omit) { const curV = getAudioPlayerAtomValue() if (!v.src || (curV.src === v.src && curV.status === "playing")) { return } const routeParams = getRouteParams() setAudioPlayerAtomValue({ ...curV, ...v, status: "loading", show: true, listId: routeParams.listId, isStream: false, }) const currentUrl = parseSafeUrl(this.audio.src)?.toString() ?? this.audio.src const newUrl = parseSafeUrl(v.src)?.toString() ?? v.src // It seems that audio load from local file has some limitations, i think reset the audio should be fine here if (currentUrl !== newUrl || newUrl.startsWith("file://")) { this.audio.src = v.src this.audio.currentTime = v.currentTime ?? curV.currentTime ?? 0 } this.audio.volume = curV.volume ?? 0.8 this.audio.playbackRate = curV.playbackRate ?? 1 this.currentTimeTimer && clearInterval(this.currentTimeTimer) this.currentTimeTimer = setInterval(() => { setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), currentTime: this.audio.currentTime, }) }, 1000) this.audio.onloadedmetadata = () => { if (Number.isNaN(this.audio.duration) || this.audio.duration === Infinity) { this.audio.currentTime = 0 } } const currentActionId = this.__currentActionId return this.audio .play() .then(() => { if (currentActionId !== this.__currentActionId) return setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), status: "playing", duration: this.audio.duration === Infinity ? 0 : this.audio.duration, }) }) .catch(noop) }, teardown() { this.currentTimeTimer && clearInterval(this.currentTimeTimer) this.audio.pause() }, play() { ++this.__currentActionId const curV = getAudioPlayerAtomValue() if (curV.isStream) { void this.audio.play().catch(noop) setAudioPlayerAtomValue({ ...curV, status: "playing", }) return } this.mount(curV) }, pause() { ++this.__currentActionId const curV = getAudioPlayerAtomValue() if (curV.status === "paused") { return } setAudioPlayerAtomValue({ ...curV, status: "paused", currentTime: this.audio.currentTime, }) this.teardown() return }, togglePlayAndPause() { const curV = getAudioPlayerAtomValue() if (curV.isStream) { if (curV.status === "playing") { return this.pause() } if (curV.status === "paused") { return this.play() } return this.pause() } if (curV.status === "playing") { return this.pause() } else if (curV.status === "paused") { return this.mount(curV) } else { return this.pause() } }, close() { setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), show: false, status: "paused", isStream: false, }) this.teardown() }, seek(time: number) { if (getAudioPlayerAtomValue().isStream) { return } this.audio.currentTime = time setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), currentTime: time, }) }, setPlaybackRate(speed: number) { if (getAudioPlayerAtomValue().isStream) { return } this.audio.playbackRate = speed setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), playbackRate: speed, }) }, back(time: number) { if (getAudioPlayerAtomValue().isStream) { return } this.seek(Math.max(this.audio.currentTime - time, 0)) }, forward(time: number) { if (getAudioPlayerAtomValue().isStream) { return } this.seek(Math.min(this.audio.currentTime + time, this.audio.duration)) }, toggleMute() { this.audio.muted = !this.audio.muted setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), isMute: this.audio.muted, }) }, setVolume(volume: number) { this.audio.volume = volume setAudioPlayerAtomValue({ ...getAudioPlayerAtomValue(), volume, }) }, } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/popover.ts ================================================ import type { PopoverContentProps } from "@radix-ui/react-popover" import { atom } from "jotai" import type { ReactNode } from "react" import { createAtomHooks, jotaiStore } from "~/lib/jotai" // Atom export interface PopoverProps extends Omit { /** Custom z-index for popover */ zIndex?: number /** Whether the popover should close when clicked outside */ modal?: boolean } type PopoverState = | { open: false } | { open: true position: { x: number; y: number } content: ReactNode props?: PopoverProps // Just for abort callback abortController: AbortController } export const [popoverAtom, usePopoverState, usePopoverValue, useSetPopover] = createAtomHooks( atom({ open: false }), ) export const showPopover = ( mouseXY: { x: number; y: number }, element: ReactNode, props?: PopoverProps, ) => { jotaiStore.set(popoverAtom, { open: true, position: mouseXY, content: element, props, abortController: new AbortController(), }) } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/preview.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" export const [, , , , previewBackPath, setPreviewBackPath] = createAtomHooks(atom()) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/readability.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" const mergeObjectSetter = (setter: (prev: T) => void, getter: () => T) => (value: Partial) => setter({ ...getter(), ...value }) export enum ReadabilityStatus { INITIAL = 1, WAITING = 2, SUCCESS = 3, FAILURE = 4, } export const [ , , useReadabilityStatus, , getReadabilityStatus, __setReadabilityStatus, useReadabilityStatusSelector, ] = createAtomHooks(atom>({})) export const setReadabilityStatus = mergeObjectSetter(__setReadabilityStatus, getReadabilityStatus) export const useEntryIsInReadability = (entryId?: string) => useReadabilityStatusSelector( (map) => (entryId ? (map[entryId] ? isInReadability(map[entryId]) : false) : false), [entryId], ) export const useEntryIsInReadabilitySuccess = (entryId?: string) => useReadabilityStatusSelector( (map) => (entryId ? map[entryId] === ReadabilityStatus.SUCCESS : false), [entryId], ) export const useEntryInReadabilityStatus = (entryId?: string) => useReadabilityStatusSelector( (map) => (entryId ? map[entryId] || ReadabilityStatus.INITIAL : ReadabilityStatus.INITIAL), [entryId], ) export const isInReadability = (status: ReadabilityStatus) => status !== ReadabilityStatus.INITIAL && !!status ================================================ FILE: apps/desktop/layer/renderer/src/atoms/server-configs.ts ================================================ import { getStorageNS } from "@follow/utils/ns" import type { ExtractResponseData, GetStatusConfigsResponse } from "@follow-app/client-sdk" import PKG from "@pkg" import { atomWithStorage } from "jotai/utils" import { createAtomHooks } from "~/lib/jotai" export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks( atomWithStorage>>( getStorageNS("server-configs"), null, undefined, { getOnInit: true, }, ), ) export type ServerConfigs = ExtractResponseData export type PaymentPlan = ServerConfigs["PAYMENT_PLAN_LIST"][number] export type PaymentFeature = PaymentPlan["limit"] export const useIsInMASReview = () => { const serverConfigs = useServerConfigs() return ( typeof process !== "undefined" && process.mas && serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version ) } export const getIsInMASReview = () => { const serverConfigs = getServerConfigs() return ( typeof process !== "undefined" && process.mas && serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version ) } export const useIsPaymentEnabled = () => { const serverConfigs = useServerConfigs() const isInMASReview = useIsInMASReview() return !isInMASReview && serverConfigs?.PAYMENT_ENABLED } export const getIsPaymentEnabled = () => { const serverConfigs = getServerConfigs() const isInMASReview = getIsInMASReview() return !isInMASReview && serverConfigs?.PAYMENT_ENABLED } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/settings/ai.ts ================================================ import { createSettingAtom } from "@follow/atoms/helper/setting.js" import { defaultAISettings } from "@follow/shared/settings/defaults" import type { AISettings, AIShortcut, AIShortcutTarget, MCPService, } from "@follow/shared/settings/interface" import { DEFAULT_SHORTCUT_TARGETS } from "@follow/shared/settings/interface" import { jotaiStore } from "@follow/utils" import type { ExtractResponseData, GetStatusConfigsResponse } from "@follow-app/client-sdk" import { clamp } from "es-toolkit" import { atom, useAtomValue } from "jotai" import { getFeature } from "~/hooks/biz/useFeature" export interface WebAISettings extends AISettings { panelStyle: AIChatPanelStyle showSplineButton: boolean } type ServerShortcutConfig = ExtractResponseData["AI_SHORTCUTS"][number] const FALLBACK_SHORTCUT_ICON = "i-mgc-hotkey-cute-re" const VALID_SHORTCUT_TARGETS = new Set(DEFAULT_SHORTCUT_TARGETS) const isValidShortcutTarget = (target: string): target is AIShortcutTarget => VALID_SHORTCUT_TARGETS.has(target as AIShortcutTarget) const sanitizeShortcutTargets = (targets?: readonly string[]): AIShortcutTarget[] => { if (!targets || targets.length === 0) { return [...DEFAULT_SHORTCUT_TARGETS] } const filtered = targets.filter(isValidShortcutTarget) as AIShortcutTarget[] return filtered.length > 0 ? [...filtered] : [...DEFAULT_SHORTCUT_TARGETS] } const normalizeShortcut = (shortcut: AIShortcut): AIShortcut => { return { ...shortcut, displayTargets: sanitizeShortcutTargets(shortcut.displayTargets), enabled: typeof shortcut.enabled === "boolean" ? shortcut.enabled : true, } } const normalizeShortcuts = (shortcuts: readonly AIShortcut[] | undefined): AIShortcut[] => (shortcuts ?? []).map((shortcut) => normalizeShortcut({ ...shortcut })) const mergeWithServerShortcuts = ( localShortcuts: readonly AIShortcut[], serverShortcuts: readonly ServerShortcutConfig[], ): AIShortcut[] => { const normalizedLocal = normalizeShortcuts(localShortcuts) if (serverShortcuts.length === 0) { return normalizedLocal } const serverShortcutMap = new Map() serverShortcuts.forEach((shortcut) => { serverShortcutMap.set(shortcut.id, shortcut) }) const seenServerShortcutIds = new Set() const mergedShortcuts: AIShortcut[] = [] normalizedLocal.forEach((shortcut) => { const serverShortcut = serverShortcutMap.get(shortcut.id) if (!serverShortcut) { mergedShortcuts.push(shortcut) return } seenServerShortcutIds.add(serverShortcut.id) const shouldClearPrompt = shortcut.prompt === serverShortcut.defaultPrompt mergedShortcuts.push({ ...shortcut, name: shortcut.name || serverShortcut.name, prompt: shouldClearPrompt ? "" : shortcut.prompt, defaultPrompt: serverShortcut.defaultPrompt, displayTargets: sanitizeShortcutTargets( shortcut.displayTargets || serverShortcut.displayTargets, ), }) }) serverShortcuts.forEach((serverShortcut) => { if (seenServerShortcutIds.has(serverShortcut.id)) return mergedShortcuts.push({ id: serverShortcut.id, name: serverShortcut.name, prompt: "", defaultPrompt: serverShortcut.defaultPrompt, enabled: true, icon: FALLBACK_SHORTCUT_ICON, displayTargets: sanitizeShortcutTargets(serverShortcut.displayTargets), }) }) return mergedShortcuts } export const getShortcutEffectivePrompt = (shortcut: AIShortcut): string => { return shortcut.prompt || shortcut.defaultPrompt || "" } export const isServerShortcut = (shortcut: AIShortcut) => !!shortcut.defaultPrompt export const createDefaultSettings = (): WebAISettings => ({ ...defaultAISettings, shortcuts: normalizeShortcuts(defaultAISettings.shortcuts), panelStyle: AIChatPanelStyle.Floating, showSplineButton: true, }) export const { useSettingKey: useAISettingKey, useSettingSelector: useAISettingSelector, setSetting: setAISetting, clearSettings: clearAISettings, initializeDefaultSettings, getSettings: getAISettings, useSettingValue: useAISettingValue, settingAtom: __aiSettingAtom, } = createSettingAtom("ai", createDefaultSettings) export const aiServerSyncWhiteListKeys = [] export const syncServerShortcuts = ( serverShortcuts: readonly ServerShortcutConfig[] | null | undefined, ) => { const storedShortcuts = getAISettings().shortcuts ?? [] const serverShortcutList = Array.isArray(serverShortcuts) ? serverShortcuts : [] const mergedShortcuts = mergeWithServerShortcuts(storedShortcuts, serverShortcutList) setAISetting("shortcuts", mergedShortcuts) } ////////// AI Panel Style export enum AIChatPanelStyle { Fixed = "fixed", Floating = "floating", } export const useAIChatPanelStyle = () => useAISettingKey("panelStyle") export const setAIChatPanelStyle = (style: AIChatPanelStyle) => { setAISetting("panelStyle", style) } export const getAIChatPanelStyle = () => getAISettings().panelStyle // Floating panel state atoms interface FloatingPanelState { width: number height: number x: number y: number } const DEFAULT_FLOATING_PANEL_WIDTH = 500 const DEFAULT_FLOATING_PANEL_HEIGHT = clamp(window.innerHeight * 0.9, 600, 1000) const DEFAULT_FLOATING_PANEL_X = window.innerWidth - DEFAULT_FLOATING_PANEL_WIDTH - 20 const DEFAULT_FLOATING_PANEL_Y = window.innerHeight - DEFAULT_FLOATING_PANEL_HEIGHT - 20 const defaultFloatingPanelState: FloatingPanelState = { width: DEFAULT_FLOATING_PANEL_WIDTH, height: DEFAULT_FLOATING_PANEL_HEIGHT, x: DEFAULT_FLOATING_PANEL_X, y: DEFAULT_FLOATING_PANEL_Y, } const floatingPanelStateAtom = atom(defaultFloatingPanelState) export const useFloatingPanelState = () => useAtomValue(floatingPanelStateAtom) export const setFloatingPanelState = (state: Partial) => { const currentState = jotaiStore.get(floatingPanelStateAtom) jotaiStore.set(floatingPanelStateAtom, { ...currentState, ...state }) } export const getFloatingPanelState = () => jotaiStore.get(floatingPanelStateAtom) ////////// AI Panel Visibility const aiPanelVisibilityAtom = atom(false) export const useAIPanelVisibility = () => useAtomValue(aiPanelVisibilityAtom) export const setAIPanelVisibility = (visibility: boolean) => { const aiEnabled = getFeature("ai") if (aiEnabled) { jotaiStore.set(aiPanelVisibilityAtom, visibility) } } export const getAIPanelVisibility = () => jotaiStore.get(aiPanelVisibilityAtom) ////////// MCP Services export const useMCPEnabled = () => useAISettingKey("mcpEnabled") export const setMCPEnabled = (enabled: boolean) => { setAISetting("mcpEnabled", enabled) } export const useMCPServices = () => useAISettingKey("mcpServices") export const addMCPService = (service: Omit) => { const services = getAISettings().mcpServices const newService = { ...service, id: Date.now().toString(), } setAISetting("mcpServices", [...services, newService]) return newService.id } export const updateMCPService = (id: string, updates: Partial) => { const services = getAISettings().mcpServices const updatedServices = services.map((service) => service.id === id ? { ...service, ...updates } : service, ) setAISetting("mcpServices", updatedServices) } export const removeMCPService = (id: string) => { const services = getAISettings().mcpServices const filteredServices = services.filter((service) => service.id !== id) setAISetting("mcpServices", filteredServices) } //// Enhance Init Ai Settings export const initializeDefaultAISettings = () => { initializeDefaultSettings() } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/settings/general.ts ================================================ import { createSettingAtom } from "@follow/atoms/helper/setting.js" import { defaultGeneralSettings } from "@follow/shared/settings/defaults" import { hookEnhancedSettings as baseHookEnhancedSettings } from "@follow/shared/settings/hook" import type { GeneralSettings } from "@follow/shared/settings/interface" import type { SupportedLanguages } from "@follow-app/client-sdk" import { jotaiStore } from "~/lib/jotai" import { getDefaultLanguage } from "~/lib/language" export const DEFAULT_ACTION_LANGUAGE = "default" export const createDefaultGeneralSettings = (): GeneralSettings => ({ ...defaultGeneralSettings, language: getDefaultLanguage(), }) const { useSettingKey: useGeneralSettingKeyInternal, useSettingSelector: useGeneralSettingSelectorInternal, useSettingKeys: useGeneralSettingKeysInternal, setSetting: setGeneralSetting, clearSettings: clearGeneralSettings, initializeDefaultSettings: initializeDefaultGeneralSettings, getSettings: getGeneralSettingsInternal, useSettingValue: useGeneralSettingValueInternal, settingAtom: __generalSettingAtom, } = createSettingAtom("general", createDefaultGeneralSettings) export const hookEnhancedSettings = < T1 extends (key: any) => any, T2 extends (selector: (s: any) => any) => any, T3 extends (keys: any) => any, T4 extends () => any, T5 extends () => any, >( useSettingKey: T1, useSettingSelector: T2, useSettingKeys: T3, getSettings: T4, useSettingValue: T5, enhancedSettingKeys: Set, defaultSettings: Record, ): [T1, T2, T3, T4, T5] => { return baseHookEnhancedSettings( useSettingKey, useSettingSelector, useSettingKeys, getSettings, useSettingValue, enhancedSettingKeys, defaultSettings, { useEnhancedEnabled: () => useGeneralSettingKeyInternal("enhancedSettings"), getEnhancedEnabled: () => jotaiStore.get(__generalSettingAtom).enhancedSettings, }, ) } export function useActionLanguage() { const actionLanguage = useGeneralSettingSelectorInternal((s) => s.actionLanguage) const language = useGeneralSettingSelectorInternal((s) => s.language) return ( actionLanguage === DEFAULT_ACTION_LANGUAGE ? language : actionLanguage ) as SupportedLanguages } export function getActionLanguage() { const { actionLanguage, language } = getGeneralSettingsInternal() return ( actionLanguage === DEFAULT_ACTION_LANGUAGE ? language : actionLanguage ) as SupportedLanguages } export function useHideAllReadSubscriptions() { const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") const unreadOnly = useGeneralSettingKey("unreadOnly") return hideAllReadSubscriptions && unreadOnly } export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [ "appLaunchOnStartup", "sendAnonymousData", "language", "voice", ] export const enhancedGeneralSettingKeys = new Set([ "groupByDate", "autoExpandLongSocialMedia", ]) const [ useGeneralSettingKey, useGeneralSettingSelector, useGeneralSettingKeys, getGeneralSettings, useGeneralSettingValue, ] = hookEnhancedSettings( useGeneralSettingKeyInternal, useGeneralSettingSelectorInternal, useGeneralSettingKeysInternal, getGeneralSettingsInternal, useGeneralSettingValueInternal, enhancedGeneralSettingKeys, defaultGeneralSettings, ) export { __generalSettingAtom, clearGeneralSettings, getGeneralSettings, initializeDefaultGeneralSettings, setGeneralSetting, useGeneralSettingKey, useGeneralSettingKeys, useGeneralSettingSelector, useGeneralSettingValue, } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/settings/integration.ts ================================================ import { createSettingAtom } from "@follow/atoms/helper/setting.js" import { IN_ELECTRON } from "@follow/shared/constants" import { defaultIntegrationSettings } from "@follow/shared/settings/defaults" import type { IntegrationSettings } from "@follow/shared/settings/interface" export const createDefaultSettings = (): IntegrationSettings => { const defaultSettings = { ...defaultIntegrationSettings } // Only include useBrowserFetch setting in Electron environment if (!IN_ELECTRON) { // Remove useBrowserFetch setting for non-Electron environments const { useBrowserFetch, ...settingsWithoutBrowserFetch } = defaultSettings return settingsWithoutBrowserFetch as IntegrationSettings } // Check if we have stored settings that might need migration const storedSettings = (() => { try { const stored = localStorage.getItem("follow:integration") return stored ? JSON.parse(stored) : null } catch { return null } })() if (storedSettings?.customIntegration) { return { ...defaultSettings, ...storedSettings, } } return defaultSettings } export const { useSettingKey: useIntegrationSettingKey, useSettingSelector: useIntegrationSettingSelector, setSetting: setIntegrationSetting, clearSettings: clearIntegrationSettings, initializeDefaultSettings: initializeDefaultIntegrationSettings, getSettings: getIntegrationSettings, useSettingValue: useIntegrationSettingValue, } = createSettingAtom("integration", createDefaultSettings) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/settings/ui.ts ================================================ import { createSettingAtom } from "@follow/atoms/helper/setting.js" import { defaultUISettings } from "@follow/shared/settings/defaults" import type { UISettings } from "@follow/shared/settings/interface" import { getDefaultLanguage } from "~/lib/language" import { DEFAULT_ACTION_ORDER } from "~/modules/customize-toolbar/constant" import { hookEnhancedSettings } from "./general" export const createDefaultUISettings = (): UISettings => ({ ...defaultUISettings, // Action Order toolbarOrder: DEFAULT_ACTION_ORDER, // Discover discoverLanguage: getDefaultLanguage().startsWith("zh") ? "all" : "eng", accentColor: "orange", }) const { useSettingKey: useUISettingKeyInternal, useSettingSelector: useUISettingSelectorInternal, useSettingKeys: useUISettingKeysInternal, setSetting: setUISetting, clearSettings: clearUISettings, initializeDefaultSettings: initializeDefaultUISettings, getSettings: getUISettingsInternal, useSettingValue: useUISettingValueInternal, settingAtom: __uiSettingAtom, } = createSettingAtom("ui", createDefaultUISettings) export const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [ "uiFontFamily", "readerFontFamily", "opaqueSidebar", "accentColor", // "customCSS", ] export const enhancedUISettingKeys = new Set([ "hideExtraBadge", "codeHighlightThemeLight", "codeHighlightThemeDark", "dateFormat", "readerRenderInlineStyle", "modalOverlay", "reduceMotion", "usePointerCursor", "opaqueSidebar", ]) const [useUISettingKey, useUISettingSelector, useUISettingKeys, getUISettings, useUISettingValue] = hookEnhancedSettings( useUISettingKeyInternal, useUISettingSelectorInternal, useUISettingKeysInternal, getUISettingsInternal, useUISettingValueInternal, enhancedUISettingKeys, defaultUISettings, ) export { __uiSettingAtom, clearUISettings, getUISettings, initializeDefaultUISettings, setUISetting, useUISettingKey, useUISettingKeys, useUISettingSelector, useUISettingValue, } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/sidebar.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" const [ , , internal_useSubscriptionColumnShow, , internal_getSubscriptionShow, setTimelineColumnShow, ] = createAtomHooks(atom(true)) export const useSubscriptionColumnShow = internal_useSubscriptionColumnShow export const getSubscriptionColumnShow = internal_getSubscriptionShow export { setTimelineColumnShow } export const [ , , useSubscriptionColumnTempShow, , getSubscriptionColumnTempShow, setSubscriptionColumnTempShow, ] = createAtomHooks(atom(false)) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/source-content.tsx ================================================ import { atom } from "jotai" import { useCallback } from "react" import { useModalStack } from "~/components/ui/modal/stacked/hooks" import { createAtomHooks } from "~/lib/jotai" import { SourceContentView } from "~/modules/entry-content/components/SourceContentView" export const [, , useShowSourceContent, , getShowSourceContent, setShowSourceContent] = createAtomHooks(atom(false)) export const toggleShowSourceContent = () => setShowSourceContent(!getShowSourceContent()) export const enableShowSourceContent = () => setShowSourceContent(true) export const resetShowSourceContent = () => setShowSourceContent(false) export const useSourceContentModal = () => { const { present } = useModalStack() return useCallback( ({ title, src }: { title?: string; src: string }) => { present({ id: src, title, content: () => , resizeable: true, clickOutsideToDismiss: true, max: true, }) }, [present], ) } ================================================ FILE: apps/desktop/layer/renderer/src/atoms/updater.ts ================================================ import type { StoreDistribution } from "@follow-app/client-sdk" import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" export type UpdaterStatus = "ready" type UpdaterStatusKind = "app" | "renderer" | "pwa" | "distribution" type BaseUpdaterStatus = { type: T status: UpdaterStatus finishUpdate?: () => void } type AppUpdaterStatus = BaseUpdaterStatus<"app"> type RendererUpdaterStatus = BaseUpdaterStatus<"renderer"> type PwaUpdaterStatus = BaseUpdaterStatus<"pwa"> type DistributionUpdaterStatus = BaseUpdaterStatus<"distribution"> & { distribution: StoreDistribution storeUrl: string storeVersion: string | null currentVersion: string | null } export type UpdaterStatusAtom = | AppUpdaterStatus | RendererUpdaterStatus | PwaUpdaterStatus | DistributionUpdaterStatus | null export const [, , useUpdaterStatus, , getUpdaterStatus, setUpdaterStatus] = createAtomHooks( atom(null as UpdaterStatusAtom), ) ================================================ FILE: apps/desktop/layer/renderer/src/atoms/user.ts ================================================ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" export const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] = createAtomHooks(atom(false)) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/AppErrorBoundary.tsx ================================================ import type { FC, PropsWithChildren } from "react" import { createElement, Suspense, useCallback } from "react" import { getErrorFallback } from "../errors" import type { ErrorComponentType } from "../errors/enum" import PageErrorFallback from "../errors/PageError" import type { FallbackRender } from "./ErrorBoundary" import { ErrorBoundary } from "./ErrorBoundary" export interface AppErrorBoundaryProps extends PropsWithChildren { height?: number | string errorType: ErrorComponentType } export const AppErrorBoundary: FC< Omit & { errorType: ErrorComponentType[] | ErrorComponentType } > = ({ errorType, children }) => { if (Array.isArray(errorType)) { return ( <> {errorType.reduceRight( (acc, type) => ( {acc} ), children, )} ) } return {children} } type ErrorFallbackProps = Parameters["0"] export type AppErrorFallbackProps = ErrorFallbackProps & {} const AppErrorBoundaryItem: FC = ({ errorType, children }) => { const fallbackRender = useCallback( (fallbackProps: ErrorFallbackProps) => { const errorElement = getErrorFallback(errorType) if (!errorElement) { return } return {createElement(getErrorFallback(errorType), fallbackProps)} }, [errorType], ) return {children} } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ErrorBoundary.tsx ================================================ import { tracker } from "@follow/tracker" import type { PropsWithChildren, ReactNode } from "react" import type { FallbackProps } from "react-error-boundary" import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary" export type ErrorFallbackProps = Omit & FallbackProps & { resetError: () => void } export type FallbackRender = (props: ErrorFallbackProps) => ReactNode interface ErrorBoundaryProps extends PropsWithChildren { fallback?: FallbackRender fallbackRender?: FallbackRender handled?: boolean beforeCapture?: (scope: unknown, error: unknown) => unknown } const emptyFallback: FallbackRender = () => null export const ErrorBoundary = ({ children, fallback, fallbackRender, beforeCapture, }: ErrorBoundaryProps) => { const renderFallback = fallbackRender ?? fallback ?? emptyFallback const handleError = (rawError: unknown, info: { componentStack?: string | null }) => { const error = rawError instanceof Error ? rawError : new Error(String(rawError)) if (beforeCapture?.(info, error) === false) { return } void tracker.manager.captureException(error, { source: "desktop_error_boundary", component_stack: info.componentStack, }) } return ( renderFallback({ ...props, resetError: props.resetErrorBoundary, }) } > {children} ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ErrorElement.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import { tracker } from "@follow/tracker" import { useEffect, useRef } from "react" import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router" import { toast } from "sonner" import { removeAppSkeleton } from "~/lib/app" import { attachOpenInEditor } from "~/lib/dev" import { getNewIssueUrl } from "~/lib/issues" import { clearLocalPersistStoreData } from "~/store/utils/clear" import { PoweredByFooter } from "./PoweredByFooter" export function ErrorElement() { const error = useRouteError() const navigate = useNavigate() const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : JSON.stringify(error) const stack = error instanceof Error ? error.stack : null useEffect(() => { removeAppSkeleton() }, []) useEffect(() => { console.error("Error handled by React Router default ErrorBoundary:", error) void tracker.manager.captureException(error, { source: "desktop_router_error_element", }) }, [error]) const reloadRef = useRef(false) if ( message.startsWith("Failed to fetch dynamically imported module") && window.sessionStorage.getItem("reload") !== "1" ) { if (reloadRef.current) return null toast.info("Web app has been updated so it needs to be reloaded.") window.sessionStorage.setItem("reload", "1") window.location.reload() reloadRef.current = true return null } return (

Sorry, {APP_NAME} has encountered an error

{message}

{import.meta.env.DEV && stack ? (
          {attachOpenInEditor(stack)}
        
) : null}

{APP_NAME} has a temporary problem, click the button below to try reloading the app or another solution?

) } export const FeedbackIssue = (_props: { message: string stack: string | null | undefined error?: unknown }) => (

Still having this issue? Please give feedback in GitHub, thanks! Submit Issue

) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ErrorTooltip.tsx ================================================ import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger, } from "@follow/components/ui/tooltip/index.js" import dayjs from "dayjs" import { useTranslation } from "react-i18next" export function ErrorTooltip({ errorAt, errorMessage, children, showWhenError = false, }: { errorMessage?: string | null errorAt?: string | null children: React.ReactNode showWhenError?: boolean }) { const { t } = useTranslation() if (!errorAt || !errorMessage) { return showWhenError ? children : null } return ( {children}
{t("feed_item.error_since")}{" "} {dayjs.duration(dayjs(errorAt).diff(dayjs(), "minute"), "minute").humanize(true)}
{!!errorMessage && (
{errorMessage}
)}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ExPromise.tsx ================================================ import { useLayoutEffect, useState } from "react" import type { JSX } from "react/jsx-runtime" const NOT_RESOLVED = Symbol("NOT_RESOLVED") export const ExPromise = ({ children, promise, }: { promise: Promise children: (value: T) => JSX.Element }) => { // use() is a hook that returns the value of the promise, but in react 19 const [value, setValue] = useState(NOT_RESOLVED) useLayoutEffect(() => { promise.then(setValue) }, [promise]) return value === NOT_RESOLVED ? null : children(value as T) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/Focusable.tsx ================================================ import type { FocusableProps } from "@follow/components/common/Focusable/Focusable.js" import { Focusable as FocusableComponent } from "@follow/components/common/Focusable/Focusable.js" import { FloatingLayerScope, HotkeyScope } from "~/constants" interface BizFocusableProps extends Omit { scope: HotkeyScope } export const Focusable = FocusableComponent as Component< Prettify & React.DetailedHTMLProps, HTMLDivElement> > export const FocusablePresets = { isNotFloatingLayerScope: (v: Set) => !FloatingLayerScope.some((s) => v.has(s)), isSubscriptionList: (scope: Set) => { return ( scope.has(HotkeyScope.SubscriptionList) || (scope.has(HotkeyScope.Home) && scope.size === 1) ) }, isSubscriptionOrTimeline: (v: Set) => { return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0 }, isTimeline: (v) => v.has(HotkeyScope.Timeline) && !v.has(HotkeyScope.EntryRender), isEntryRender: (v) => v.has(HotkeyScope.EntryRender), isAIChat: (v) => v.has(HotkeyScope.AIChat), } satisfies Record) => boolean> ================================================ FILE: apps/desktop/layer/renderer/src/components/common/Fragment.tsx ================================================ import type { FC, ReactNode } from "react" import { Fragment } from "react" export const SafeFragment: FC<{ children: ReactNode }> = ({ children, ..._rest }) => ( {children} ) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ImpressionTracker.tsx ================================================ import type { AllTrackers, TrackerPoints } from "@follow/tracker" import { tracker } from "@follow/tracker" import { memo, useState } from "react" import { useInView } from "react-intersection-observer" type ImpressionProps = { event: T onTrack?: () => any // @ts-expect-error FIXME properties?: Parameters children: React.ReactNode } export function ImpressionView( props: ImpressionProps & { shouldTrack?: boolean }, ) { const { shouldTrack = true, ...rest } = props if (!shouldTrack) { return <>{props.children} } return } function ImpressionViewImpl(props: ImpressionProps) { const [impression, setImpression] = useState(false) const { ref } = useInView({ initialInView: false, triggerOnce: true, onChange(inView) { if (!inView) { return } setImpression(true) // @ts-expect-error tracker[props.event]?.apply(null, props.properties) props.onTrack?.() }, }) return ( <> {props.children} {!impression && } ) } const MemoImpressionViewImpl = memo(ImpressionViewImpl) MemoImpressionViewImpl.displayName = "ImpressionView" ================================================ FILE: apps/desktop/layer/renderer/src/components/common/LCPEndDetector.tsx ================================================ import { jotaiStore } from "@follow/utils" import { atom } from "jotai" import { useEffect } from "react" const LCPEndAtom = atom(false) /** * To skip page transition when first load, improve LCP */ export const LCPEndDetector = () => { useEffect(() => { let hasEnded = false const timeoutIds: Array> = [] const rafIds: number[] = [] const idleCallbackIds: number[] = [] const scheduleRaf = (cb: FrameRequestCallback) => { if (typeof window !== "undefined" && window.requestAnimationFrame) { const id = window.requestAnimationFrame(cb) rafIds.push(id) } else { const id = setTimeout(() => cb(performance.now()), 16) timeoutIds.push(id) } } const markEnded = () => { if (hasEnded) return hasEnded = true // Defer to ensure layout/paint and initial CSS transitions settle scheduleRaf(() => { scheduleRaf(() => { // Prefer idle if available to avoid jank const ric = (typeof window !== "undefined" && (window as any).requestIdleCallback) as | ((cb: () => void, opts?: { timeout?: number }) => number) | undefined if (ric) { const id = ric(() => jotaiStore.set(LCPEndAtom, true), { timeout: 200, }) as unknown as number idleCallbackIds.push(id) } else { const id = setTimeout(() => jotaiStore.set(LCPEndAtom, true), 0) timeoutIds.push(id) } }) }) } // If PerformanceObserver for LCP is available, prefer it const supportsPO = typeof PerformanceObserver !== "undefined" let po: PerformanceObserver | undefined const onHidden = () => markEnded() const onVisibilityChange = () => { if (document.visibilityState === "hidden") onHidden() } const onPageHide = onHidden const onLoad = () => markEnded() let safetyTimer: ReturnType | undefined let fallbackEndTimer: ReturnType | undefined if (supportsPO) { try { po = new PerformanceObserver((list) => { const entries = list.getEntries() if (entries && entries.length > 0) { // Any LCP entry indicates a meaningful paint; we can mark end soon markEnded() } }) // buffered: true ensures we get entries that occurred before observer creation po.observe({ type: "largest-contentful-paint", buffered: true } as PerformanceObserverInit) } catch { // Ignore observer errors and rely on fallback } // When the page is hidden or unloaded, LCP is finalized window.addEventListener("visibilitychange", onVisibilityChange, { once: true }) window.addEventListener("pagehide", onPageHide, { once: true }) window.addEventListener("load", onLoad, { once: true }) // Absolute safety net: if nothing fires, end after 3s safetyTimer = setTimeout(() => markEnded(), 3000) timeoutIds.push(safetyTimer) } else { // Ultimate fallback for environments without PO fallbackEndTimer = setTimeout(() => { jotaiStore.set(LCPEndAtom, true) }, 2000) timeoutIds.push(fallbackEndTimer) } return () => { if (po) po.disconnect() const caf = typeof window !== "undefined" && window.cancelAnimationFrame if (caf) { rafIds.forEach((id) => (window.cancelAnimationFrame as (h: number) => void)(id)) } const cic = (typeof window !== "undefined" && (window as any).cancelIdleCallback) as | ((id: number) => void) | undefined if (cic) idleCallbackIds.forEach((id) => cic(id)) timeoutIds.forEach((id) => clearTimeout(id)) if (safetyTimer) clearTimeout(safetyTimer) if (fallbackEndTimer) clearTimeout(fallbackEndTimer) window.removeEventListener("visibilitychange", onVisibilityChange) window.removeEventListener("pagehide", onPageHide) window.removeEventListener("load", onLoad) } }, []) return null } // eslint-disable-next-line react-refresh/only-export-components export const isLCPEnded = () => jotaiStore.get(LCPEndAtom) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/LoadMoreIndicator.tsx ================================================ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx" import { useInView } from "react-intersection-observer" export const LoadMoreIndicator: Component<{ onLoading: () => void }> = ({ onLoading, children, className }) => { const { ref } = useInView({ rootMargin: "1px", onChange(inView) { if (inView) onLoading() }, }) return (
{children ?? }
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/LoadRemixAsyncComponent.tsx ================================================ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx" import type { FC, ReactNode } from "react" import { createElement, useEffect, useState } from "react" export const LoadRemixAsyncComponent: FC<{ loader: () => Promise Header: FC<{ loader: () => any; [key: string]: any }> }> = ({ loader, Header }) => { const [loading, setLoading] = useState(true) const [Component, setComponent] = useState<{ c: () => ReactNode }>({ c: () => null, }) useEffect(() => { let isUnmounted = false setLoading(true) loader() .then((module) => { if (!module.Component) { return } if (isUnmounted) return const { loader } = module setComponent({ c: () => ( <>
), }) }) .finally(() => { setLoading(false) }) return () => { isUnmounted = true } }, [Header, loader]) if (loading) { return (
) } return createElement(Component.c) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/Motion.tsx ================================================ import type { TargetAndTransition } from "motion/react" import { m as M } from "motion/react" import { createElement } from "react" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import { isLCPEnded } from "./LCPEndDetector" type WithLCPOptimization

= P & { lcpOptimization?: boolean } // Narrow exported proxy type so each motion component accepts `lcpOptimization` export type MotionProxy = { [K in keyof typeof M]: (typeof M)[K] extends React.ComponentType ? React.ComponentType> : (typeof M)[K] } const cacheMap = new Map() export const m: MotionProxy = new Proxy(M, { get(target, p: string) { const Component = target[p] if (cacheMap.has(p)) { return cacheMap.get(p) } const MotionComponent = ({ ref, lcpOptimization, ...props }) => { const shouldReduceMotion = useReduceMotion() const nextProps = { ...props } if (shouldReduceMotion) { if (props.exit) { nextProps.exit = { opacity: 0, transition: (props.exit as TargetAndTransition).transition, } } if (props.initial) { nextProps.initial = { opacity: 0, } } nextProps.animate = { opacity: 1, } } // Disable initial animation before hydration ends to optimize LCP if (lcpOptimization && !isLCPEnded()) { nextProps.initial = false } return createElement(Component, { ...nextProps, ref }) } cacheMap.set(p, MotionComponent) return MotionComponent }, }) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/NotFound.tsx ================================================ import { Logo } from "@follow/components/icons/logo.jsx" import { Button } from "@follow/components/ui/button/index.js" import { ELECTRON_BUILD } from "@follow/shared/constants" import { useEffect } from "react" import type { Location } from "react-router" import { Navigate, useLocation, useNavigate } from "react-router" import { useSyncTheme } from "~/hooks/common" import { removeAppSkeleton } from "~/lib/app" import { PoweredByFooter } from "./PoweredByFooter" class AccessNotFoundError extends Error { constructor( message: string, public path: string, public location: Location, ) { super(message) this.name = "AccessNotFoundError" } override toString() { return `${this.name}: ${this.message} at ${this.path}` } } export const NotFound = () => { const location = useLocation() useSyncTheme() useEffect(() => { if (!ELECTRON_BUILD) { return } console.error( new AccessNotFoundError( "Electron app got to a 404 page, this should not happen", location.pathname, location, ), ) }, [location]) useEffect(() => { removeAppSkeleton() }, []) const navigate = useNavigate() if (location.pathname.endsWith("/index.html")) { return } return (

You have come to a desert of knowledge where there is nothing.

Current path: {location.pathname}

) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/PoweredByFooter.tsx ================================================ import { Folo } from "@follow/components/icons/folo.js" import { Logo } from "@follow/components/icons/logo.jsx" import { cn } from "@follow/utils/utils" import pkg from "@pkg" export const PoweredByFooter: Component = ({ className }) => (
{new Date().getFullYear()} {" "}
) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ProviderComposer.tsx ================================================ "use client" import type { JSX } from "react" import { cloneElement } from "react" export const ProviderComposer: Component<{ contexts: JSX.Element[] }> = ({ contexts, children }) => contexts.reduceRight( (kids: any, parent: any) => cloneElement(parent, { children: kids }), children, ) ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ReloadPrompt.tsx ================================================ import { useEffect } from "react" import { useRegisterSW } from "virtual:pwa-register/react" import { setUpdaterStatus } from "~/atoms/updater" // check for updates every hour const period = 60 * 60 * 1000 export function ReloadPrompt() { const { needRefresh: [needRefresh], updateServiceWorker, } = useRegisterSW({ onRegisteredSW(swUrl, r) { if (period <= 0) return if (r?.active?.state === "activated") { registerPeriodicSync(period, swUrl, r) } else if (r?.installing) { r.installing.addEventListener("statechange", (e) => { const sw = e.target as ServiceWorker if (sw.state === "activated") registerPeriodicSync(period, swUrl, r) }) } }, }) useEffect(() => { if (needRefresh) { setUpdaterStatus({ type: "pwa", status: "ready", finishUpdate: () => { updateServiceWorker(true) }, }) } }, [needRefresh, updateServiceWorker]) return null } /** * This function will register a periodic sync check every hour, you can modify the interval as needed. */ function registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) { if (period <= 0) return setInterval(async () => { if ("onLine" in navigator && !navigator.onLine) return const resp = await fetch(swUrl, { cache: "no-store", headers: { cache: "no-store", "cache-control": "no-cache", }, }) if (resp?.status === 200) await r.update() }, period) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/ShadowDOM.tsx ================================================ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js" import { useIsDark } from "@follow/hooks" import { getAccentColorValue } from "@follow/shared/settings/constants" import { hexToHslString } from "@follow/utils" import { nanoid } from "nanoid" import type { FC, PropsWithChildren, ReactNode } from "react" import { createContext, createElement, use, useLayoutEffect, useMemo, useState } from "react" import root from "react-shadow" import { useUISettingKeys } from "~/atoms/settings/ui" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import type { TextSelectionEvent } from "~/lib/simple-text-selection" import { addTextSelectionListener } from "~/lib/simple-text-selection" const ShadowDOMContext = createContext(false) const weakMapElementKey = new WeakMap() const cloneStylesElement = () => { const $styles = document.head.querySelectorAll("style").values() const reactNodes = [] as ReactNode[] for (const $style of $styles) { let key = weakMapElementKey.get($style) if (!key) { key = nanoid(8) weakMapElementKey.set($style, key) } reactNodes.push( createElement(MemoedDangerousHTMLStyle, { key, children: $style.innerHTML, }), ) const styles = getLinkedStaticStyleSheets() for (const style of styles) { let key = weakMapElementKey.get(style.ref) if (!key) { key = nanoid(8) weakMapElementKey.set(style.ref, key) } reactNodes.push( createElement(MemoedDangerousHTMLStyle, { key, children: style.cssText, ["data-href"]: style.ref.href, }), ) } } return reactNodes } export const ShadowDOM: FC< PropsWithChildren> & { injectHostStyles?: boolean textSelectionEnabled?: boolean onTextSelect?: (event: TextSelectionEvent) => void onSelectionClear?: () => void } > & { useIsShadowDOM: () => boolean } = (props) => { const { injectHostStyles = true, textSelectionEnabled = false, onTextSelect, onSelectionClear, ...rest } = props const [stylesElements, setStylesElements] = useState(() => injectHostStyles ? cloneStylesElement() : [], ) const [el, setEl] = useState<{ shadowRoot: ShadowRoot } | null>(null) useLayoutEffect(() => { if (!el) return const { shadowRoot } = el if (!textSelectionEnabled || !shadowRoot || !onTextSelect) return return addTextSelectionListener(shadowRoot, onTextSelect, onSelectionClear) }, [textSelectionEnabled, onTextSelect, onSelectionClear, el]) useLayoutEffect(() => { if (!injectHostStyles) return const mutationObserver = new MutationObserver(() => { setStylesElements(cloneStylesElement()) }) mutationObserver.observe(document.head, { childList: true, subtree: true, }) return () => { mutationObserver.disconnect() } }, [injectHostStyles]) const dark = useIsDark() const reduceMotion = useReduceMotion() const [uiFont, usePointerCursor, accentColor] = useUISettingKeys([ "uiFontFamily", "usePointerCursor", "accentColor", ]) return ( // @ts-expect-error
({ fontFamily: `${uiFont},"SN Pro", system-ui, sans-serif`, "--pointer": usePointerCursor ? "pointer" : "default", "--fo-a": hexToHslString(getAccentColorValue(accentColor)[dark ? "dark" : "light"]), }), [uiFont, usePointerCursor, accentColor, dark], )} id="shadow-html" data-motion-reduce={reduceMotion} data-theme={dark ? "dark" : "light"} className="font-theme" > {injectHostStyles ? stylesElements : null} {props.children}
) } ShadowDOM.useIsShadowDOM = () => use(ShadowDOMContext) const cacheCssTextMap = {} as Record function getLinkedStaticStyleSheets() { const $links = document.head .querySelectorAll("link[rel=stylesheet]") .values() as unknown as HTMLLinkElement[] const styleSheetMap = new WeakMap() const cssArray = [] as { cssText: string; ref: HTMLLinkElement }[] for (const sheet of document.styleSheets) { if (!sheet.href) continue if (!sheet.ownerNode) continue styleSheetMap.set(sheet.ownerNode, sheet) } for (const $link of $links) { const sheet = styleSheetMap.get($link) if (!sheet) continue if (!sheet.href) continue const hasCache = cacheCssTextMap[sheet.href] if (!hasCache) { if (!sheet.href) continue try { const rules = sheet.cssRules || sheet.rules let cssText = "" for (const rule of rules) { cssText += rule.cssText } cacheCssTextMap[sheet.href] = cssText } catch (err) { console.error("Failed to get cssText for", sheet.href, err) } } cssArray.push({ cssText: cacheCssTextMap[sheet.href]!, ref: $link, }) } return cssArray } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/SharePanel.tsx ================================================ import { IN_ELECTRON } from "@follow/shared/constants" import { getEntry } from "@follow/store/entry/getter" import { cn } from "@follow/utils/utils" import { useCallback } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { ipcServices } from "~/lib/client" import { copyToClipboard } from "~/lib/clipboard" interface SharePanelProps { entryId: string } interface ShareOption { id: string label: string icon: string action: () => Promise | void color?: string bgColor?: string } interface SocialShareOption { id: string label: string icon: string url: string color: string bgColor: string } const socialOptions: SocialShareOption[] = [ { id: "twitter", label: "X", icon: tw`i-mgc-social-x-cute-re`, url: "https://x.com/intent/tweet?text={text}&url={url}", color: "text-white", bgColor: "bg-black", }, { id: "facebook", label: "Facebook", icon: tw`i-mgc-facebook-cute-re`, url: "https://www.facebook.com/sharer/sharer.php?u={url}", color: "text-white", bgColor: "bg-[#1877F2]", }, { id: "telegram", label: "Telegram", icon: tw`i-mgc-telegram-cute-re`, url: "https://t.me/share/url?url={url}&text={text}", color: "text-white", bgColor: "bg-[#0088CC]", }, { id: "weibo", label: "微博", icon: tw`i-mgc-weibo-cute-re`, url: "https://service.weibo.com/share/share.php?url={url}&title={text}", color: "text-white", bgColor: "bg-[#E6162D]", }, ] const getShareUrl = (entryId: string) => { const entry = getEntry(entryId) if (!entry) return "" // Temporarily use the original link return entry.url! // const params = getRouteParams() // let subscriptionId = "all" // if (params.feedId) { // subscriptionId = params.feedId // } else if (params.inboxId) { // subscriptionId = params.inboxId // } else if (params.listId) { // subscriptionId = params.listId // } // return UrlBuilder.shareEntry(entryId, { // view: params.view, // subscriptionId, // }) } export const SharePanel = ({ entryId }: SharePanelProps) => { const { t } = useTranslation() const generateShareContent = useCallback( (entry: ReturnType) => { if (!entry) return null const { title, description } = entry const shareUrl = getShareUrl(entryId) // Limit text to 50 characters with ellipsis const truncateText = (text: string, maxLength = 50) => { return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text } const shareTitle = `${title || t("share.default_title")} - Folo` const baseText = description || title || t("share.default_description") const truncatedText = truncateText(baseText) const shareText = `${truncatedText} | ${t("share.discover_more")}` return { title: shareTitle, text: shareText, url: shareUrl, } }, [entryId, t], ) const handleNativeShare = useCallback(async () => { const entry = getEntry(entryId) const shareContent = generateShareContent(entry) if (!shareContent) return try { if (IN_ELECTRON) { // Use Electron's share menu await ipcServices?.menu.showShareMenu(shareContent.url) } else if (navigator.share) { // Use Web Share API await navigator.share({ title: shareContent.title, text: shareContent.text, url: shareContent.url, }) } else { // Fallback to copying link await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) } } catch { // If sharing fails, copy link as fallback try { await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) } catch { toast.error(t("share.copy_failed")) } } }, [entryId, generateShareContent, t]) const handleCopyLink = useCallback(async () => { const shareUrl = getShareUrl(entryId) try { await copyToClipboard(shareUrl) toast.success(t("share.link_copied")) } catch { toast.error(t("share.copy_failed")) } }, [entryId, t]) const handleSocialShare = useCallback( (shareUrlTemplate: string) => { const entry = getEntry(entryId) const shareContent = generateShareContent(entry) if (!shareContent) return const encodedUrl = encodeURIComponent(shareContent.url) const shareTitle = encodeURIComponent(shareContent.title) const shareText = encodeURIComponent(shareContent.text) const finalUrl = shareUrlTemplate .replace("{url}", encodedUrl) .replace("{title}", shareTitle) .replace("{text}", shareText) window.open(finalUrl, "_blank", "width=600,height=400") }, [entryId, generateShareContent], ) const actionOptions: ShareOption[] = [ ...(IN_ELECTRON || (typeof navigator !== "undefined" && "share" in navigator) ? [ { id: "native-share", label: t("share.system_share"), icon: "i-mgc-share-forward-cute-re", action: handleNativeShare, color: "text-blue-500", }, ] : []), { id: "copy-link", label: t("share.copy_link"), icon: "i-mgc-link-cute-re", action: handleCopyLink, }, ] return (

{t("share.title")}

{(() => { const entry = getEntry(entryId) const title = entry?.title return title ? (

{title}

) : null })()}

{t("share.social_media")}

{socialOptions.map((option) => ( ))}

{t("share.actions")}

{actionOptions.map((option) => ( ))}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/common/withAppErrorBoundary.tsx ================================================ import type { FC } from "react" import { createElement } from "react" import type { ErrorComponentType } from "../errors/enum" import { AppErrorBoundary } from "./AppErrorBoundary" interface WithErrorBoundaryOptions { errorType: ErrorComponentType | ErrorComponentType[] height?: number | string } /** * Higher-order component that wraps a component with AppErrorBoundary * @param Component - The component to wrap with ErrorBoundary * @param options - Configuration options for the ErrorBoundary wrapper * @returns A new component wrapped with ErrorBoundary */ export function withAppErrorBoundary

( Component: FC

, options: WithErrorBoundaryOptions, ): FC

{ const { errorType, height } = options const WrappedComponent = (props: P) => { return createElement(AppErrorBoundary, { errorType, height }, createElement(Component, props)) } WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || "Component"})` return WrappedComponent as FC

} ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/EntryNotFound.tsx ================================================ import { Logo } from "@follow/components/icons/logo.jsx" import { Button } from "@follow/components/ui/button/index.js" import type { FC } from "react" import { useNavigate } from "react-router" import { CustomSafeError } from "../../errors/CustomSafeError" import type { AppErrorFallbackProps } from "../common/AppErrorBoundary" import { useResetErrorWhenRouteChange } from "./helper" const EntryNotFoundErrorFallback: FC = ({ resetError, error }) => { if (!(error instanceof EntryNotFound)) { throw error } useResetErrorWhenRouteChange(resetError) const navigate = useNavigate() return (

The entry you're looking for could not be found. It may have been removed or the URL is incorrect.

) } export default EntryNotFoundErrorFallback export class EntryNotFound extends CustomSafeError { constructor() { super("Entry 404") } } ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/FeedNotFound.tsx ================================================ import { Logo } from "@follow/components/icons/logo.jsx" import { Button } from "@follow/components/ui/button/index.js" import type { FC } from "react" import { useNavigate } from "react-router" import { CustomSafeError } from "../../errors/CustomSafeError" import type { AppErrorFallbackProps } from "../common/AppErrorBoundary" import { useResetErrorWhenRouteChange } from "./helper" const FeedNotFoundErrorFallback: FC = ({ resetError, error }) => { if (!(error instanceof FeedNotFound)) { throw error } useResetErrorWhenRouteChange(resetError) const navigate = useNavigate() return (

There is no feed with the given ID. Please check the URL and retry.

) } export default FeedNotFoundErrorFallback export class FeedNotFound extends CustomSafeError { constructor() { super("Feed 404") } } ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/ModalError.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import type { FC } from "react" import { attachOpenInEditor } from "~/lib/dev" import type { AppErrorFallbackProps } from "../common/AppErrorBoundary" import { FeedbackIssue } from "../common/ErrorElement" import { m } from "../common/Motion" import { useCurrentModal } from "../ui/modal/stacked/hooks" import { parseError } from "./helper" const ModalErrorFallback: FC = (props) => { const { message, stack } = parseError(props.error) const modal = useCurrentModal() return (
{message}
{import.meta.env.DEV && stack ? (
            {attachOpenInEditor(stack)}
          
) : null}

{APP_NAME} has a temporary problem, click the button below to try reloading the app or another solution?

) } export default ModalErrorFallback ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/PageError.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import type { FC } from "react" import { attachOpenInEditor } from "~/lib/dev" import type { AppErrorFallbackProps } from "../common/AppErrorBoundary" import { FeedbackIssue } from "../common/ErrorElement" import { parseError, useResetErrorWhenRouteChange } from "./helper" const PageErrorFallback: FC = (props) => { const { message, stack } = parseError(props.error) useResetErrorWhenRouteChange(props.resetError) return (
{message}
{import.meta.env.DEV && stack ? (
            {attachOpenInEditor(stack)}
          
) : null}

{APP_NAME} has a temporary problem, click the button below to try reloading the app or another solution?

) } export default PageErrorFallback ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/RSSHubError.tsx ================================================ import type { FC } from "react" import { attachOpenInEditor } from "~/lib/dev" import type { AppErrorFallbackProps } from "../common/AppErrorBoundary" import { FeedbackIssue } from "../common/ErrorElement" import { parseError } from "./helper" const RSSHubErrorFallback: FC = (props) => { const { message, stack } = parseError(props.error) return (

RSSHub has a temporary problem, please contact the our team.

{message}
{import.meta.env.DEV && stack ? (
            {attachOpenInEditor(stack)}
          
) : null}
) } export default RSSHubErrorFallback ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/enum.ts ================================================ export enum ErrorComponentType { Modal = "Modal", Page = "Page", // Feed FeedFoundCanBeFollow = "FeedFoundCanBeFollow", FeedNotFound = "FeedNotFound", // Section RSSHubDiscoverError = "RSSHubDiscoverError", EntryNotFound = "EntryNotFound", } ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/helper.ts ================================================ import type { FC } from "react" import { createElement, useEffect, useRef } from "react" import { useLocation } from "react-router" import type { AppErrorFallbackProps } from "../common/AppErrorBoundary" export const parseError = (error: unknown): { message?: string; stack?: string } => { if (error instanceof Error) { return { message: error.message, stack: error.stack, } } else { return { message: String(error), stack: undefined, } } } export const useResetErrorWhenRouteChange = (resetError: () => void) => { const location = useLocation() const currentPathnameRef = useRef(location.pathname) const onceRef = useRef(false) useEffect(() => { if (onceRef.current) { return } if (currentPathnameRef.current !== location.pathname) { resetError() onceRef.current = true } }, [location.pathname]) } export const withErrorGrand = T>( error: S, Component: FC, ): FC => { return (props: AppErrorFallbackProps) => { if (!(props.error instanceof error)) { throw error } return createElement(Component, props) } } ================================================ FILE: apps/desktop/layer/renderer/src/components/errors/index.ts ================================================ import { lazy } from "react" import { ErrorComponentType } from "./enum" const ErrorFallbackMap = { [ErrorComponentType.Modal]: lazy(() => import("./ModalError")), [ErrorComponentType.Page]: lazy(() => import("./PageError")), [ErrorComponentType.FeedNotFound]: lazy(() => import("./FeedNotFound")), [ErrorComponentType.RSSHubDiscoverError]: lazy(() => import("./RSSHubError")), [ErrorComponentType.EntryNotFound]: lazy(() => import("./EntryNotFound")), } export const getErrorFallback = (type: ErrorComponentType) => ErrorFallbackMap[type] ================================================ FILE: apps/desktop/layer/renderer/src/components/mobile/button.tsx ================================================ import { MotionButtonBase } from "@follow/components/ui/button/index.js" import { cn } from "@follow/utils/utils" export const HeaderTopReturnBackButton: Component<{ to?: string }> = ({ className, to }) => ( window.history.returnBack(to)} className={cn("center size-8", className)} > Back ) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/ai-summary-card/AISummaryCardBase.tsx ================================================ import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js" import { MotionButtonBase } from "@follow/components/ui/button/index.js" import { cn } from "@follow/utils/utils" import { FollowAPIError } from "@follow-app/client-sdk" import type { ReactNode } from "react" import { useTranslation } from "react-i18next" import { useIsPaymentEnabled } from "~/atoms/server-configs" import { CopyButton } from "~/components/ui/button/CopyButton" import { Markdown } from "~/components/ui/markdown/Markdown" import { useFeature } from "~/hooks/biz/useFeature" import { useSettingModal } from "~/modules/settings/modal/useSettingModal" interface AISummaryCardBaseProps { /** Summary content to display */ content?: string | null /** Whether the summary is currently loading */ isLoading?: boolean /** Additional className for the container */ className?: string /** Custom header content (replaces default AI Summary header) */ headerContent?: ReactNode /** Additional content to render below the summary */ footerContent?: ReactNode /** Custom loading state component */ loadingComponent?: ReactNode /** Title text for the AI Summary header */ title?: string /** Whether to show the copy button */ showCopyButton?: boolean /** Whether to show the Ask AI button when there's content */ showAskAIButton?: boolean /** Callback when Ask AI button is clicked */ onAskAI?: () => void error?: Error | null } const DefaultLoadingState = () => (
) const DefaultEmptyState = ({ message, shouldSuggestUpgrade, }: { message: string shouldSuggestUpgrade?: boolean }) => { const settingModalPresent = useSettingModal() const { t } = useTranslation("app") if (shouldSuggestUpgrade) { return ( ) } return (

{message}

) } export const AISummaryCardBase: React.FC = ({ content, isLoading = false, className, headerContent, footerContent, loadingComponent, title = "AI Summary", showCopyButton = true, showAskAIButton = false, onAskAI, error, }) => { const { t } = useTranslation("app") const aiEnabled = useFeature("ai") const hasContent = !isLoading && content const shouldSuggestUpgrade = useIsPaymentEnabled() && error instanceof FollowAPIError ? error.status === 402 : undefined return (
{/* Animated background gradient */}
{/* Subtle shine effect on hover */}
{/* Header */}
{headerContent || (
{/* Glowing AI icon */}
{title}
)}
{aiEnabled && showAskAIButton && hasContent && onAskAI && ( Ask AI )} {showCopyButton && hasContent && ( )}
{/* Content */} {isLoading ? ( loadingComponent || ) : hasContent ? ( {String(content)} ) : shouldSuggestUpgrade ? ( ) : ( )} {footerContent}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/ai-summary-card/index.ts ================================================ export { AISummaryCardBase } from "./AISummaryCardBase" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/auto-completion/AutoCompletion.tsx ================================================ import { Input } from "@follow/components/ui/input/index.js" import { RootPortal } from "@follow/components/ui/portal/index.jsx" import { useCorrectZIndex } from "@follow/components/ui/z-index/ctx.js" import { stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react" import Fuse from "fuse.js" import { AnimatePresence, m } from "motion/react" import { Fragment, memo, useCallback, useEffect, useState } from "react" export type Suggestion = { name: string value: string } export interface AutocompleteProps extends React.InputHTMLAttributes { suggestions: Suggestion[] renderSuggestion?: (suggestion: Suggestion) => any onSuggestionSelected: (suggestion: NoInfer | null) => void // classnames searchKeys?: string[] maxHeight?: number } const defaultSearchKeys = ["name", "value"] const defaultRenderSuggestion = (suggestion: any) => suggestion?.name export const Autocomplete = ({ ref: forwardedRef, suggestions, renderSuggestion = defaultRenderSuggestion, onSuggestionSelected, maxHeight, value, searchKeys = defaultSearchKeys, defaultValue, ...inputProps }: AutocompleteProps & { ref?: React.Ref }) => { const [selectedOptions, setSelectedOptions] = useState | null>( () => suggestions.find((suggestion) => suggestion.value === value) || null, ) const [filterableSuggestions, setFilterableSuggestions] = useState(suggestions) const doFilter = useCallback(() => { const fuse = new Fuse(suggestions, { keys: searchKeys, }) const trimInputValue = (value as string)?.trim() if (!trimInputValue) return setFilterableSuggestions(suggestions) const results = fuse.search(trimInputValue) setFilterableSuggestions(results.map((result) => result.item)) }, [suggestions, value, searchKeys]) useEffect(() => { doFilter() }, [doFilter]) const zIndex = useCorrectZIndex(9) return ( { setSelectedOptions(suggestion) onSuggestionSelected(suggestion) }} > {({ open }) => { return ( {open && filterableSuggestions.length > 0 && (
{filterableSuggestions.map((suggestion) => ( ))}
)}
) }}
) } const MemoizedComboboxOption = memo(({ suggestion }: { suggestion: Suggestion }) => { return ( {suggestion.name} ) }) Autocomplete.displayName = "Autocomplete" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/auto-completion/index.ts ================================================ export * from "./AutoCompletion" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx ================================================ import { SYSTEM_CAN_UNDER_BLUR_WINDOW } from "@follow/shared/constants" import { cn } from "@follow/utils/utils" import type * as React from "react" import type { ComponentPropsWithoutRef, ElementType } from "react" import { useUISettingKey } from "~/atoms/settings/ui" type Props = { as?: T ref?: React.Ref } & ComponentPropsWithoutRef const MacOSVibrancy = ({ children, as, ...rest }: Props) => { const Component = as || "div" return {children} } const Noop = ({ children, className, as, ...rest }: Props) => { const Component = as || "div" return ( {children} ) } export const WindowUnderBlur = SYSTEM_CAN_UNDER_BLUR_WINDOW ? (props: Props) => { const opaqueSidebar = useUISettingKey("opaqueSidebar") if (opaqueSidebar) { return } if (!window.electron) { return } switch (window.electron.process.platform) { case "darwin": { return } case "win32": { return } default: { return } } } : Noop ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/background/index.ts ================================================ export * from "./WindowUnderBlur" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/button/AnimatedCommandButton.tsx ================================================ import { MotionButtonBase } from "@follow/components/ui/button/index.js" import { useTypeScriptHappyCallback } from "@follow/hooks" import { cn } from "@follow/utils/utils" import type { VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority" import type { HTMLMotionProps, Variants } from "motion/react" import { AnimatePresence, m } from "motion/react" import type { FC } from "react" import * as React from "react" import { cloneElement, useRef, useState } from "react" const animatedCommandButtonVariants = cva( ["center pointer-events-auto flex text-xs", "rounded-md p-1.5 duration-200"], { variants: { variant: { solid: ["border-accent/5 bg-accent/80 text-white border backdrop-blur"], outline: ["text-accent hover:bg-material-ultra-thick"], ghost: [ "border-accent/5 bg-accent/80 text-accent border backdrop-blur", "bg-theme-item-active hover:bg-theme-item-hover", ], }, }, defaultVariants: { variant: "solid", }, }, ) interface AnimatedCommandButtonProps extends VariantProps { icon: React.JSX.Element } const iconVariants: Variants = { initial: { opacity: 1, scale: 1, }, animate: { opacity: 1, scale: 1, }, exit: { opacity: 0, scale: 0, }, } export const AnimatedCommandButton: FC> = ({ icon, className, style, variant, ...props }) => { const [pressed, setPressed] = useState(false) const timerRef = useRef(undefined) return ( { setPressed(true) props.onClick?.(e) timerRef.current = setTimeout(() => { setPressed(false) }, 2000) }, [props.onClick], )} style={style} > {pressed ? ( ) : ( cloneElement(icon, { className: cn(icon.props.className, "size-4"), ...iconVariants, }) )} ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/button/CommandActionButton.tsx ================================================ import type { ActionButtonProps } from "@follow/components/ui/button/action-button.js" import { ActionButton } from "@follow/components/ui/button/action-button.js" import { useCommand } from "~/modules/command/hooks/use-command" import type { FollowCommandId } from "~/modules/command/types" export interface CommandActionButtonProps extends ActionButtonProps { commandId: FollowCommandId onClick: () => void } export const CommandActionButton = ({ ref, ...props }: CommandActionButtonProps & { ref?: React.Ref }) => { const { commandId, ...rest } = props const command = useCommand(commandId) if (!command) return null const { icon, label } = command return ( ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/button/CopyButton.tsx ================================================ import { useCallback, useRef } from "react" import { m } from "~/components/common/Motion" import { copyToClipboard } from "~/lib/clipboard" import { AnimatedCommandButton } from "./AnimatedCommandButton" export const CopyButton: Component<{ value: string style?: React.CSSProperties variant?: "solid" | "outline" | "ghost" }> = ({ value, className, style, variant = "solid" }) => { const copiedTimerRef = useRef(undefined) const handleCopy = useCallback(() => { copyToClipboard(value) clearTimeout(copiedTimerRef.current) }, [value]) return ( } onClick={handleCopy} /> ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/button/GlassButton.tsx ================================================ import { Spring } from "@follow/components/constants/spring.js" import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger, } from "@follow/components/ui/tooltip/index.js" import { cn } from "@follow/utils/utils" import { cva } from "class-variance-authority" import { m } from "motion/react" import type { FC, ReactNode } from "react" export interface GlassButtonProps { description?: string onClick?: () => void className?: string testId?: string children: ReactNode /** * Custom animation variants for hover and tap states */ hoverScale?: number tapScale?: number /** * Size variant */ size?: "sm" | "md" | "lg" /** * Color theme */ theme?: "light" | "dark" | "auto" /** * Visual variant */ variant?: "glass" | "flat" } const glassButtonVariants = cva( [ // Base styles - perfect 1:1 circle "pointer-events-auto relative flex items-center justify-center rounded-full", "transition-all duration-300 ease-out no-drag-region", ], { variants: { size: { sm: "size-8 text-sm", md: "size-10 text-lg", lg: "size-12 text-xl", }, theme: { light: ["text-gray-700 hover:text-gray-900"], dark: ["text-white hover:text-white"], auto: ["text-text hover:text-text-vibrant"], }, variant: { glass: ["backdrop-blur-md border shadow-lg"], flat: ["border shadow-sm hover:shadow-md"], }, }, compoundVariants: [ // Glass variant themes { variant: "glass", theme: "light", className: [ "bg-material-thin hover:bg-material-medium", "border-gray/30 hover:border-gray/40", "shadow-gray/30", ], }, { variant: "glass", theme: "dark", className: [ "bg-material-ultra-thin hover:bg-material-thin", "border-gray/10 hover:border-gray/20", "shadow-black/25", ], }, { variant: "glass", theme: "auto", className: [ "bg-material-thin hover:bg-material-medium", "border-gray/30 hover:border-gray/40", "shadow-gray/30", ], }, // Flat variant themes { variant: "flat", theme: "light", className: [ "bg-white/80 hover:bg-white/90", "border-gray/20 hover:border-gray/30", // Subtle shadow color for clearer hover feedback "shadow-gray/10 hover:shadow-gray/25", ], }, { variant: "flat", theme: "dark", className: [ "bg-fill-secondary hover:bg-fill-tertiary", "border-gray/20 hover:border-gray/30", "shadow-black/10 hover:shadow-black/25", ], }, { variant: "flat", theme: "auto", className: [ "bg-white/80 hover:bg-white/90 dark:bg-fill-secondary dark:hover:bg-fill-tertiary", "border-gray/20 hover:border-gray/30", "shadow-gray/10 hover:shadow-gray/25 dark:shadow-black/10 dark:hover:shadow-black/25", ], }, ], defaultVariants: { size: "md", theme: "auto", variant: "glass", }, }, ) const glassOverlayVariants = cva( "absolute inset-0 rounded-full bg-gradient-to-t opacity-0 transition-opacity duration-300 hover:opacity-100", { variants: { theme: { light: "from-material-opaque/10 to-material-opaque/30", dark: "from-material-opaque/5 to-material-opaque/20", auto: "from-material-opaque/10 to-material-opaque/30", }, }, defaultVariants: { theme: "auto", }, }, ) const glassInnerShadowVariants = cva("absolute inset-0 rounded-full shadow-inner", { variants: { theme: { light: "shadow-gray/20", dark: "shadow-black/10", auto: "shadow-gray/20 dark:shadow-black/10", }, }, defaultVariants: { theme: "auto", }, }) export const GlassButton: FC = ({ description, onClick, className, testId, children, hoverScale = 1.1, tapScale = 0.95, size = "md", theme = "auto", variant = "flat", }) => { return ( { e.stopPropagation() onClick?.() }} className={cn(glassButtonVariants({ size, theme, variant }), className)} initial={{ scale: 1 }} whileHover={ variant === "flat" ? undefined : { scale: hoverScale, } } whileTap={{ scale: tapScale }} transition={Spring.presets.snappy} > {/* Glass effect overlay - only for glass variant */} {variant === "glass" &&
} {/* Icon container */}
{children}
{/* Subtle inner shadow for depth - only for glass variant */} {variant === "glass" &&
} {description && ( {description} )} ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/button/HeaderActionButton.tsx ================================================ import { cn } from "@follow/utils/utils" import { m } from "motion/react" import type { ReactNode } from "react" interface HeaderActionButtonProps { children: ReactNode onClick?: () => void disabled?: boolean loading?: boolean variant?: "primary" | "accent" | "neutral" className?: string icon?: string iconClassName?: string "data-testid"?: string } export const HeaderActionButton = ({ children, onClick, disabled = false, loading = false, variant = "neutral", className, icon, iconClassName, "data-testid": testId, }: HeaderActionButtonProps) => { const getVariantStyles = () => { if (disabled) { return [ "text-text-tertiary cursor-not-allowed opacity-50", "bg-fill-quaternary border border-transparent", ] } switch (variant) { case "primary": { return [ "bg-blue/10 text-blue hover:bg-blue/20", "border border-blue/20 hover:border-blue/30", "active:bg-blue/30 active:scale-95", ] } case "accent": { return [ "bg-accent/10 text-accent hover:bg-accent/20", "border border-accent/20 hover:border-accent/30", "active:bg-accent/30 active:scale-95", ] } default: { return [ "bg-fill/10 text-text hover:bg-fill/20", "border border-fill/20 hover:border-fill/30", "active:bg-fill/30 active:scale-95", ] } } } const iconClass = loading ? "i-mgc-loading-3-cute-re animate-spin duration-500" : icon return ( {iconClass && ( )} {children} ) } interface HeaderActionGroupProps { children: ReactNode className?: string } export const HeaderActionGroup = ({ children, className }: HeaderActionGroupProps) => { return
{children}
} ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/constants/index.tsx ================================================ const LanguageAlias = { ts: "typescript", js: "javascript", tsx: "typescriptreact", jsx: "javascriptreact", md: "markdown", } const languageToIconMap = { javascriptreact: , typescriptreact: , javascript: , typescript: , html: , css: , markdown: , json: , yaml: , bash: , } export const getLanguageIcon = (language?: string) => { if (!language) return null const alias = LanguageAlias[language] if (alias) { return languageToIconMap[alias] } return languageToIconMap[language] } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/index.ts ================================================ export * from "./shiki" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx ================================================ import { ELECTRON_BUILD } from "@follow/shared/constants" import { cn } from "@follow/utils/utils" import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect" import type { FC } from "react" import { memo, useInsertionEffect, useMemo, useRef, useState } from "react" import type { BundledLanguage, BundledTheme, DynamicImportLanguageRegistration, DynamicImportThemeRegistration, } from "shiki" import { useUISettingKey } from "~/atoms/settings/ui" import { ipcServices } from "~/lib/client" import { CopyButton } from "../../button/CopyButton" import { getLanguageIcon } from "../constants" import { useShikiDefaultTheme } from "./hooks" import { shiki, shikiTransformers } from "./shared" import styles from "./shiki.module.css" export interface ShikiProps { language: string | undefined code: string attrs?: string className?: string transparent?: boolean showCopy?: boolean theme?: string } let langModule: Record | null = null let themeModule: Record | null = null let bundledLanguagesKeysSet: Set | null = null export const ShikiHighLighter: FC = (props) => { const { code, language, className, theme: overrideTheme } = props const [currentLanguage, setCurrentLanguage] = useState(language || "plaintext") const guessCodeLanguage = useUISettingKey("guessCodeLanguage") useInsertionEffect(() => { if (!guessCodeLanguage) return if (language || !ELECTRON_BUILD) return if (!bundledLanguagesKeysSet) { import("shiki/langs") .then(({ bundledLanguages }) => { langModule = bundledLanguages bundledLanguagesKeysSet = new Set(Object.keys(bundledLanguages)) }) .then(guessLanguage) } else { guessLanguage() } function guessLanguage() { return ipcServices?.reader.detectCodeStringLanguage({ codeString: code }).then((result) => { if (!result) { return } if (bundledLanguagesKeysSet?.has(result.languageId)) { setCurrentLanguage(result.languageId) } }) } }, [guessCodeLanguage]) const loadThemesRef = useRef([] as string[]) const loadLanguagesRef = useRef([] as string[]) const [loaded, setLoaded] = useState(false) const codeTheme = useShikiDefaultTheme(overrideTheme) useIsomorphicLayoutEffect(() => { let isMounted = true setLoaded(false) async function loadShikiLanguage(language: string, languageModule: any) { if (!shiki) return if (!shiki.getLoadedLanguages().includes(language)) { await shiki.loadLanguage(await languageModule()) } } async function loadShikiTheme(theme: string, themeModule: any) { if (!shiki) return if (!shiki.getLoadedThemes().includes(theme)) { await shiki.loadTheme(await themeModule()) } } async function register() { if (!currentLanguage || !codeTheme) return const [{ bundledLanguages }, { bundledThemes }] = langModule && themeModule ? [ { bundledLanguages: langModule, }, { bundledThemes: themeModule }, ] : await Promise.all([import("shiki/langs"), import("shiki/themes")]) langModule = bundledLanguages themeModule = bundledThemes if ( currentLanguage && loadLanguagesRef.current.includes(currentLanguage) && codeTheme && loadThemesRef.current.includes(codeTheme) ) { return } return Promise.all([ (async () => { if (currentLanguage) { const importFn = (bundledLanguages as any)[currentLanguage] if (!importFn) return await loadShikiLanguage(currentLanguage || "", importFn) loadLanguagesRef.current.push(currentLanguage) } })(), (async () => { if (codeTheme) { const importFn = (bundledThemes as any)[codeTheme] if (!importFn) return await loadShikiTheme(codeTheme || "", importFn) loadThemesRef.current.push(codeTheme) } })(), ]) } register().then(() => { if (isMounted) { setLoaded(true) } }) return () => { isMounted = false } }, [codeTheme, currentLanguage]) if (!loaded) { return (
        {code}
      
) } return } export const MemoizedShikiCode = memo(ShikiHighLighter) const ShikiCode: FC< ShikiProps & { codeTheme: string } > = ({ code, language, codeTheme, className, transparent, showCopy = true }) => { const rendered = useMemo(() => { try { return shiki.codeToHtml(code, { lang: language!, themes: { dark: codeTheme, light: codeTheme, }, transformers: shikiTransformers, }) } catch { return null } }, [code, language, codeTheme]) if (!rendered) { return (
        {code}
      
) } return (
{/* Inner subtle glow */}
{/* Compact Header */}
{language === "plaintext" ? (
) : (
{getLanguageIcon(language)} {language}
)}
{/* Code content */}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/hooks.ts ================================================ import { useIsDark } from "@follow/hooks" import { useUISettingSelector } from "~/atoms/settings/ui" export const useShikiDefaultTheme = (overrideTheme?: string) => { const isDark = useIsDark() const codeThemeLight = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeLight) const codeThemeDark = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeDark) return isDark ? codeThemeDark : codeThemeLight } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/index.ts ================================================ export * from "./Shiki" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shared.ts ================================================ import { transformerMetaHighlight, transformerNotationDiff, transformerNotationHighlight, } from "@shikijs/transformers" import type { ShikiTransformer } from "shiki" import { createHighlighterCoreSync, createJavaScriptRegexEngine } from "shiki" export const shikiTransformers: ShikiTransformer[] = [ transformerMetaHighlight(), transformerNotationDiff({ matchAlgorithm: "v3" }), transformerNotationHighlight({ matchAlgorithm: "v3" }), ] const js = createJavaScriptRegexEngine() export const shiki = createHighlighterCoreSync({ themes: [], langs: [], engine: js, }) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shiki.module.css ================================================ .shiki-wrapper { @apply overflow-hidden; pre { @apply bg-transparent; } &.transparent { :global { .shiki, code { @apply !bg-transparent; } } } :global { .shiki { @apply !m-0 !bg-transparent !px-0; font-family: "OperatorMonoSSmLig Nerd Font", "Cascadia Code PL", "FantasqueSansMono Nerd Font", "Operator Mono", JetBrainsMono, "Fira Code Retina", "Fira Code", "Consolas", Monaco, "Hannotate SC", monospace, -apple-system, system-ui, sans-serif; } pre { @apply !m-0 overflow-auto px-4 py-3; font-size: 0.875em; line-height: 1.6; /* Custom scrollbar */ &::-webkit-scrollbar { @apply h-1.5 w-1.5; } &::-webkit-scrollbar-track { @apply bg-transparent; } &::-webkit-scrollbar-thumb { @apply rounded-full bg-fill-tertiary transition-colors; &:hover { @apply bg-fill-secondary; } } } pre code { @apply flex flex-col; } .line { @apply block px-4 transition-colors duration-100; & > span:last-child { @apply mr-4; } /* Expand the row without content */ &::after { content: " "; } /* Subtle hover effect on lines */ &:hover { @apply bg-fill/10; } } .highlighted, .diff { @apply relative break-all; &::before { @apply absolute left-0 top-0 h-full w-0.5; content: ""; } } .diff.add { @apply bg-green/10; &::before { @apply bg-green; } &::after { content: " +"; @apply absolute left-1.5 text-xs font-semibold text-green; } } .diff.remove { @apply bg-red/10; &::before { @apply bg-red; } &::after { content: " -"; @apply absolute left-1.5 text-xs font-semibold text-red; } } .highlighted { @apply bg-accent/10; &::before { @apply bg-accent; } } } pre { @apply rounded-none; } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/crop/AvatarUploadModal.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import { DropZone } from "@follow/components/ui/drop-zone/index.js" import { useCallback, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" interface AvatarUploadModalProps { onConfirm: (blob: Blob) => Promise onCancel: () => void maxSizeKB?: number } export const AvatarUploadModal = ({ onConfirm, onCancel, maxSizeKB = 300, }: AvatarUploadModalProps) => { const { t } = useTranslation("settings") const [selectedImage, setSelectedImage] = useState(null) const [isProcessing, setIsProcessing] = useState(false) const canvasRef = useRef(null) const imageRef = useRef(null) const containerRef = useRef(null) // Crop settings const [cropData, setCropData] = useState({ x: 0, y: 0, width: 400, height: 400, }) const [isDragging, setIsDragging] = useState(false) const [resizeHandle, setResizeHandle] = useState(null) const [dragStart, setDragStart] = useState({ x: 0, y: 0, cropX: 0, cropY: 0, cropWidth: 0, cropHeight: 0, }) // Helper function: ensure the crop data is within the image boundaries and maintain the 1:1 ratio const constrainCropData = useCallback( (newCropData: typeof cropData, imageWidth: number, imageHeight: number) => { const { x, y, width, height } = newCropData // Ensure it's a square, use the larger value to avoid shrinking const size = Math.max(width, height) // Ensure the minimum size const minSize = 50 let finalSize = Math.max(size, minSize) // Ensure it's not out of bounds, if it is, shrink it to the appropriate size const maxSize = Math.min(imageWidth, imageHeight) finalSize = Math.min(finalSize, maxSize) // Adjust the position to ensure it's within the boundaries const maxX = imageWidth - finalSize const maxY = imageHeight - finalSize return { x: Math.max(0, Math.min(x, maxX)), y: Math.max(0, Math.min(y, maxY)), width: finalSize, height: finalSize, } }, [], ) const handleFileSelect = useCallback( (files: FileList) => { const file = files[0] if (!file) return if (!file.type.startsWith("image/")) { toast.error(t("profile.avatar.invalidFileType")) return } if (file.size > maxSizeKB * 1024) { toast.error(t("profile.avatar.fileTooLarge", { size: `${maxSizeKB}KB` })) return } const reader = new FileReader() reader.onload = (e) => { const result = e.target?.result as string setSelectedImage(result) } reader.readAsDataURL(file) }, [maxSizeKB, t], ) const handleImageLoad = useCallback(() => { if (imageRef.current) { const img = imageRef.current // Use the smaller side's 80% as the initial size const maxSize = Math.min(img.naturalWidth, img.naturalHeight) const size = maxSize * 0.8 const initialCropData = { x: (img.naturalWidth - size) / 2, y: (img.naturalHeight - size) / 2, width: size, height: size, } // Use the helper function to ensure the data is valid const constrainedData = constrainCropData( initialCropData, img.naturalWidth, img.naturalHeight, ) setCropData(constrainedData) } }, [constrainCropData]) const handleCropMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault() setIsDragging(true) setDragStart({ x: e.clientX, y: e.clientY, cropX: cropData.x, cropY: cropData.y, cropWidth: cropData.width, cropHeight: cropData.height, }) }, [cropData], ) const handleResizeMouseDown = useCallback( (e: React.MouseEvent, handle: string) => { e.preventDefault() e.stopPropagation() setResizeHandle(handle) setDragStart({ x: e.clientX, y: e.clientY, cropX: cropData.x, cropY: cropData.y, cropWidth: cropData.width, cropHeight: cropData.height, }) }, [cropData], ) const handleCropMouseMove = useCallback( (e: React.MouseEvent) => { if (!isDragging && !resizeHandle) return e.preventDefault() if (!imageRef.current || !containerRef.current) return const img = imageRef.current const container = containerRef.current const containerRect = container.getBoundingClientRect() // Calculate the actual display size and position of the image in the container const containerWidth = containerRect.width const containerHeight = containerRect.height const imageAspectRatio = img.naturalWidth / img.naturalHeight const containerAspectRatio = containerWidth / containerHeight let displayWidth = 0, displayHeight = 0 if (imageAspectRatio > containerAspectRatio) { // The image is wider, use the container width displayWidth = containerWidth displayHeight = containerWidth / imageAspectRatio } else { // The image is taller, use the container height displayHeight = containerHeight displayWidth = containerHeight * imageAspectRatio } const scaleX = img.naturalWidth / displayWidth const scaleY = img.naturalHeight / displayHeight const deltaX = e.clientX - dragStart.x const deltaY = e.clientY - dragStart.y if (resizeHandle) { const { cropX, cropY, cropWidth, cropHeight } = dragStart let newX = cropX let newY = cropY let newWidth = cropWidth let newHeight = cropHeight if (resizeHandle.includes("r")) newWidth += deltaX * scaleX if (resizeHandle.includes("l")) { newWidth -= deltaX * scaleX newX += deltaX * scaleX } if (resizeHandle.includes("b")) newHeight += deltaY * scaleY if (resizeHandle.includes("t")) { newHeight -= deltaY * scaleY newY += deltaY * scaleY } // Keep the aspect ratio, use the larger change value const size = Math.max(newWidth, newHeight) // Update the coordinates based on the position of the resize handle if (resizeHandle.includes("t")) newY = cropY + cropHeight - size if (resizeHandle.includes("l")) newX = cropX + cropWidth - size const newCropData = { x: newX, y: newY, width: size, height: size, } // Use the helper function to ensure the data is valid const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight) setCropData(constrainedData) } else if (isDragging) { const newX = dragStart.cropX + deltaX * scaleX const newY = dragStart.cropY + deltaY * scaleY setCropData((prev) => { const newCropData = { ...prev, x: newX, y: newY, } return constrainCropData(newCropData, img.naturalWidth, img.naturalHeight) }) } }, [isDragging, resizeHandle, dragStart, constrainCropData], ) const handleCropMouseUp = useCallback(() => { setIsDragging(false) setResizeHandle(null) }, []) const cropImage = useCallback((): Promise => { return new Promise((resolve, reject) => { if (!imageRef.current || !canvasRef.current) { reject(new Error("Image or canvas not available")) return } const canvas = canvasRef.current const ctx = canvas.getContext("2d") if (!ctx) { reject(new Error("Canvas context not available")) return } const img = imageRef.current canvas.width = 400 canvas.height = 400 ctx.drawImage(img, cropData.x, cropData.y, cropData.width, cropData.height, 0, 0, 400, 400) canvas.toBlob( (blob) => { if (blob) { resolve(blob) } else { reject(new Error("Failed to create blob")) } }, "image/jpeg", 0.9, ) }) }, [cropData]) // Preset functions const handleFullImageCrop = useCallback(() => { if (!imageRef.current) return const img = imageRef.current const size = Math.min(img.naturalWidth, img.naturalHeight) const newCropData = { x: (img.naturalWidth - size) / 2, y: (img.naturalHeight - size) / 2, width: size, height: size, } const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight) setCropData(constrainedData) }, [constrainCropData]) const handleCenterCrop = useCallback(() => { if (!imageRef.current) return const img = imageRef.current const maxSize = Math.min(img.naturalWidth, img.naturalHeight) const size = maxSize * 0.8 const newCropData = { x: (img.naturalWidth - size) / 2, y: (img.naturalHeight - size) / 2, width: size, height: size, } const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight) setCropData(constrainedData) }, [constrainCropData]) const handleConfirm = useCallback(async () => { if (!selectedImage) return try { setIsProcessing(true) const blob = await cropImage() await onConfirm(blob) } catch (error) { console.error("Error processing image:", error) toast.error(t("profile.avatar.processingError")) } finally { setIsProcessing(false) } }, [selectedImage, cropImage, onConfirm, t]) const cropStyle = useMemo(() => { if (!imageRef.current || !containerRef.current) return {} const img = imageRef.current const container = containerRef.current const containerRect = container.getBoundingClientRect() // Calculate the actual display size and position of the image in the container const containerWidth = containerRect.width const containerHeight = containerRect.height const imageAspectRatio = img.naturalWidth / img.naturalHeight const containerAspectRatio = containerWidth / containerHeight let displayWidth = 0, displayHeight = 0, offsetX = 0, offsetY = 0 if (imageAspectRatio > containerAspectRatio) { // The image is wider, use the container width displayWidth = containerWidth displayHeight = containerWidth / imageAspectRatio offsetX = 0 offsetY = (containerHeight - displayHeight) / 2 } else { // The image is taller, use the container height displayHeight = containerHeight displayWidth = containerHeight * imageAspectRatio offsetX = (containerWidth - displayWidth) / 2 offsetY = 0 } // Calculate the scale ratio const scaleX = displayWidth / img.naturalWidth const scaleY = displayHeight / img.naturalHeight return { left: `${offsetX + cropData.x * scaleX}px`, top: `${offsetY + cropData.y * scaleY}px`, width: `${cropData.width * scaleX}px`, height: `${cropData.height * scaleY}px`, } }, [cropData]) return (
{!selectedImage ? (

{t("profile.avatar.dropZoneText")}

{t("profile.avatar.dropZoneSubtext")}

) : (
Preview {/* Crop overlay */}
{/* Grid lines */}
{/* Resize handles */}
handleResizeMouseDown(e, "tl")} />
handleResizeMouseDown(e, "tr")} />
handleResizeMouseDown(e, "bl")} />
handleResizeMouseDown(e, "br")} />
{t("profile.avatar.cropInstructions")}
)}
{selectedImage ? (
) : (
)}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/datetime/index.tsx ================================================ import { getUpdateInterval } from "@follow/components/ui/datetime/utils.js" import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger, } from "@follow/components/ui/tooltip/index.js" import { stopPropagation } from "@follow/utils/dom" import dayjs from "dayjs" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useGeneralSettingSelector } from "~/atoms/settings/general" import { useUISettingKey } from "~/atoms/settings/ui" export { RelativeTime } from "@follow/components/ui/datetime/index.js" export const RelativeDay = ({ date }: { date: Date }) => { const { t } = useTranslation("common") const language = useGeneralSettingSelector((s) => s.language) const dateFormatValue = useUISettingKey("dateFormat") const formatTemplateString = "lll" const dateFormat = dateFormatValue === "default" ? formatTemplateString : dateFormatValue const formatDateString = useCallback( (date: Date) => { const now = new Date() // Remove the time part for comparison const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const inputDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()) const diffTime = nowDate.getTime() - inputDate.getTime() const diffDays = diffTime / (1000 * 3600 * 24) if (diffDays === 0) { return t("time.today") } else if (diffDays === 1) { return t("time.yesterday") } else { return dayjs(date).format("ll") } }, [t], ) const timerRef = useRef(null) const [dateString, setDateString] = useState(() => formatDateString(date)) useEffect(() => { const updateInterval = getUpdateInterval(date, 3) if (updateInterval !== null) { timerRef.current = setTimeout(() => { setDateString(formatDateString(date)) }, updateInterval) } setDateString(formatDateString(date)) return () => { timerRef.current = clearTimeout(timerRef.current) } }, [date, formatDateString, language]) const formated = useMemo(() => dayjs(date).format(dateFormat), [dateFormat, date]) if (formated === dateString) { return <>{dateString} } return ( {dateString} {formated} ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx ================================================ import { useSetGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js" import { Divider } from "@follow/components/ui/divider/Divider.js" import { Kbd } from "@follow/components/ui/kbd/Kbd.js" import { RootPortal } from "@follow/components/ui/portal/index.js" import { useTypeScriptHappyCallback } from "@follow/hooks" import { cn } from "@follow/utils/utils" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as React from "react" import { HotkeyScope } from "~/constants" const styles = { content: { backgroundImage: "linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))", boxShadow: "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)", } as React.CSSProperties, innerGlow: { background: "linear-gradient(to bottom right, hsl(var(--fo-a) / 0.01), transparent, hsl(var(--fo-a) / 0.01))", } as React.CSSProperties, } const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => { const setGlobalFocusableScope = useSetGlobalFocusableScope() return ( { if (open) { setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") } else { setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") } props.onOpenChange?.(open) }, [props.onOpenChange], )} /> ) } const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuSubTrigger = ({ ref, className, inset, children, ...props }: React.ComponentPropsWithoutRef & { inset?: boolean } & { ref?: React.Ref | null> }) => ( {children} ) DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName const DropdownMenuSubContent = ({ ref, className, ...props }: React.ComponentPropsWithoutRef & { ref?: React.Ref | null> }) => ( {/* Inner glow layer */}
{/* Content wrapper */}
{props.children}
) DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName const DropdownMenuContent = ({ ref, className, sideOffset = 4, ...props }: React.ComponentPropsWithoutRef & { ref?: React.Ref | null> }) => { return ( {/* Inner glow layer */}
{/* Content wrapper */}
{props.children}
) } DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName const DropdownMenuItem = ({ ref, className, inset, icon, active, shortcut, checked, ...props }: React.ComponentPropsWithoutRef & { inset?: boolean icon?: React.ReactNode | ((props?: { isActive?: boolean }) => React.ReactNode) active?: boolean shortcut?: string checked?: boolean } & { ref?: React.Ref | null> }) => ( {!!icon && ( {typeof icon === "function" ? icon({ isActive: active }) : icon} )} {props.children} {/* Justify Fill */} {!!icon && } {!!shortcut && ( <> {shortcut} )} {checked && !shortcut && ( <> )} ) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName const DropdownMenuCheckboxItem = ({ ref, className, children, checked, ...props }: React.ComponentPropsWithoutRef & { ref?: React.Ref | null> }) => ( {children} ) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName const DropdownMenuLabel = ({ ref, className, inset, ...props }: React.ComponentPropsWithoutRef & { inset?: boolean } & { ref?: React.Ref | null> }) => ( ) DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName const DropdownMenuSeparator = ({ ref, ...props }: React.ComponentPropsWithoutRef & { ref?: React.Ref | null> }) => ( ) DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( ) DropdownMenuShortcut.displayName = "DropdownMenuShortcut" export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/fab/FABContainer.tsx ================================================ import { RootPortal } from "@follow/components/ui/portal/index.jsx" import { useTypeScriptHappyCallback } from "@follow/hooks" import { clsx, cn } from "@follow/utils/utils" import { atom, useAtomValue } from "jotai" import type { HTMLMotionProps } from "motion/react" import { AnimatePresence } from "motion/react" import type * as React from "react" import type { FC, JSX, PropsWithChildren, ReactNode } from "react" import { useId } from "react" import { m } from "~/components/common/Motion" import { jotaiStore } from "~/lib/jotai" const fabContainerElementAtom = atom(null as HTMLDivElement | null) export interface FABConfig { id: string icon: JSX.Element onClick: () => void } export const FABBase: FC< PropsWithChildren< { id: string show?: boolean children: JSX.Element ref?: React.Ref } & HTMLMotionProps<"button"> > > = (props) => { const { children, show = true, ref, ...extra } = props const { className, ...rest } = extra return ( {show && (
{children} )} ) } export const FABPortable: FC< PropsWithChildren<{ children: React.JSX.Element onClick: () => void show?: boolean ref?: React.Ref }> > = (props) => { const { onClick, children, show = true, ref } = props const id = useId() const portalElement = useAtomValue(fabContainerElementAtom) if (!portalElement) return null return ( {children} ) } export const FABContainer = (props: { children?: ReactNode }) => { return (
jotaiStore.set(fabContainerElementAtom, ref), [])} data-testid="fab-container" data-hide-print className={clsx( "fixed bottom-[calc(2rem+env(safe-area-inset-bottom))] left-[calc(100vw-3rem-1rem)] z-[9] flex flex-col", "transition-transform duration-300 ease-in-out", )} > {props.children}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/fab/index.ts ================================================ export * from "./FABContainer" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/hover-preview/EntryPreviewCard.tsx ================================================ import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@follow/components/ui/hover-card/index.js" import { useEntry, usePrefetchEntryDetail } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" import { feedIconSelector } from "@follow/store/feed/selectors" import { cn } from "@follow/utils" import { m } from "motion/react" import * as React from "react" import { RelativeTime } from "~/components/ui/datetime" import { FeedIcon } from "~/modules/feed/feed-icon" interface EntryPreviewCardProps { entryId: string children: React.ReactNode className?: string onNavigate?: (entryId: string) => void } export const EntryPreviewCard: React.FC = ({ entryId, children, className, onNavigate, }) => { // Prefetch entry details on hover usePrefetchEntryDetail(entryId) const entry = useEntry(entryId, (state) => { if (!state) return null return { title: state.title, description: state.description, author: state.author, publishedAt: state.publishedAt, feedId: state.feedId, url: state.url, } }) const feed = useFeedById(entry?.feedId, feedIconSelector) if (!entry || !feed) { return <>{children} } return ( {children} {/* Header */} {/* Content */} ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/hover-preview/FeedPreviewCard.tsx ================================================ import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@follow/components/ui/hover-card/index.js" import { useFeedById } from "@follow/store/feed/hooks" import { feedIconSelector } from "@follow/store/feed/selectors" import { cn } from "@follow/utils" import { m } from "motion/react" import * as React from "react" import { FeedIcon } from "~/modules/feed/feed-icon" interface FeedPreviewCardProps { feedId: string children: React.ReactNode className?: string onNavigate?: (feedId: string) => void } export const FeedPreviewCard: React.FC = ({ feedId, children, className, onNavigate, }) => { const feed = useFeedById(feedId, feedIconSelector) if (!feed) { return <>{children} } return ( {children} {/* Header */} { e.preventDefault() onNavigate?.(feedId) }} target="_blank" rel="noopener noreferrer" >

{feed.title}

) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/hover-preview/index.ts ================================================ export { EntryPreviewCard } from "./EntryPreviewCard" export { FeedPreviewCard } from "./FeedPreviewCard" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/KeyRecorder.tsx ================================================ import { useReplaceGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js" import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js" import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js" import { sortShortcutKeys } from "@follow/utils/utils" import type { FC, RefObject, SVGProps } from "react" import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useOnClickOutside } from "usehooks-ts" import { HotkeyScope } from "~/constants" export interface KeyRecorderProps { onChange: (keys: string[] | null) => void onBlur: () => void } export const KeyRecorder: FC = ({ onChange, onBlur }) => { const { t } = useTranslation("shortcuts") const { currentKeys } = useShortcutRecorder() const setGlobalScope = useReplaceGlobalFocusableScope() const ref = useRef(null) useEffect(() => { const { rollback } = setGlobalScope(HotkeyScope.Recording) if (ref.current) { ref.current.focus() } return () => { rollback() } }, [setGlobalScope]) useOnClickOutside(ref as RefObject, () => { if (currentKeys.length > 0) { onChange(currentKeys) } onBlur() }) return (
{currentKeys.length > 0 ? (
{currentKeys.join("+")}
) : ( {t("settings.shortcuts.press_to_record")} )} {currentKeys.length > 0 ? t("settings.shortcuts.undo") : t("settings.shortcuts.reset")}
) } function FamiconsArrowUndoCircle(props: SVGProps) { return ( {/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */} ) } const MODIFIER_KEYS_MAP = { Control: "Control", Alt: "Alt", Shift: "Shift", Meta: "Meta", } as const const MODIFIER_KEYS_SET = new Set(Object.values(MODIFIER_KEYS_MAP)) const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/ const useShortcutRecorder = () => { const [currentKeys, setCurrentKeys] = useState([]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { event.preventDefault() event.stopPropagation() event.stopImmediatePropagation() const { altKey, ctrlKey, metaKey, shiftKey, key: eventKey } = event let mainKeyPressed = eventKey if (mainKeyPressed.length === 1 && mainKeyPressed >= "a" && mainKeyPressed <= "z") { mainKeyPressed = mainKeyPressed.toUpperCase() } else if (mainKeyPressed === " ") { mainKeyPressed = "Space" } const pressedKeysSet = new Set() if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift) if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta) if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control) if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt) // If mainKeyPressed (from event.key) is not a modifier key, add it as the main key. // If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is "Shift"), // it has already been handled and added to pressedKeysSet by the above if (shiftKey) logic, // so we don't need to add it again here. if (!MODIFIER_KEYS_SET.has(mainKeyPressed)) { pressedKeysSet.add(mainKeyPressed) } const currentCombination = Array.from(pressedKeysSet) // --- Start validation rules --- const nonModifierKeysInCombo = currentCombination.filter((key) => !MODIFIER_KEYS_SET.has(key)) // Rule 2: Pure modifier key combinations are not allowed (e.g., just Shift, or Ctrl+Alt) if (nonModifierKeysInCombo.length === 0) { // When only modifier keys are pressed, currentCombination will still contain these modifiers. // For example, pressing only Shift, currentCombination is ["Shift"] // Here we don't update the state, indicating this is an invalid recording. // You can provide temporary UI feedback here, e.g.: "Recording: Shift" console.info( "Recording (invalid - modifiers only):", sortShortcutKeys(currentCombination).join(" + "), ) return } // Typically shortcuts have only one "main" function key (e.g., Ctrl+A, Shift+F1) // If multiple non-modifier keys are detected (e.g., theoretically user pressing A and B simultaneously), // this is usually not a standard shortcut recording scenario // This check is mainly for code robustness, as `keydown` events typically focus on one main key at a time. if (nonModifierKeysInCombo.length > 1) { console.warn( "Recording (invalid - multiple main keys, this shouldn't normally happen):", sortShortcutKeys(currentCombination).join(" + "), ) return } const primaryKey = nonModifierKeysInCombo[0] // Rule 3: Fn keys (F1-F12) can be single keys or modifier+Fn key combinations if (F_KEY_REGEX.test(primaryKey ?? "")) { setCurrentKeys(sortShortcutKeys(currentCombination)) return } // Rule 1: Single "ASCII" main keys are allowed (here referring to all non-modifier, non-F keys) // Examples: A, 1, Space, Enter, ArrowUp, etc. They can be used alone or with modifiers. // For these keys, as long as they're not pure modifier combinations, they're considered valid. setCurrentKeys(sortShortcutKeys(currentCombination)) } window.addEventListener("keydown", handleKeyDown) return () => { window.removeEventListener("keydown", handleKeyDown) } }, [setCurrentKeys]) return { currentKeys } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/index.ts ================================================ export * from "./KeyRecorder" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/HTML.tsx ================================================ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js" import katexStyle from "katex/dist/katex.min.css?raw" import { createElement, Fragment, memo, useEffect, useImperativeHandle, useMemo, useState, } from "react" import type { JSX } from "react/jsx-runtime" import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom" import { parseHtml } from "~/lib/parse-html" import { useWrappedElementSize } from "~/providers/wrapped-element-provider" import { MediaContainerWidthProvider } from "../media/MediaContainerWidthProvider" import type { MediaInfoRecord } from "../media/MediaInfoRecord" import { MediaInfoRecordProvider } from "../media/MediaInfoRecordProvider" import { MarkdownRenderContainerRefContext } from "./context" export type HTMLProps = { children: string | null | undefined as: A accessory?: React.ReactNode noMedia?: boolean mediaInfo?: Nullable } & JSX.IntrinsicElements[A] & Partial<{ renderInlineStyle: boolean }> const HTMLImpl = (props: HTMLProps) => { const { children, renderInlineStyle, as = "div", accessory, noMedia, mediaInfo, ref, ...rest } = props const [remarkOptions, setRemarkOptions] = useState({ renderInlineStyle, noMedia, }) const [shouldForceReMountKey, setShouldForceReMountKey] = useState(0) useEffect(() => { setRemarkOptions((options) => { if (JSON.stringify(options) === JSON.stringify({ renderInlineStyle, noMedia })) { return options } setShouldForceReMountKey((key) => key + 1) return { ...options, renderInlineStyle, noMedia } }) }, [renderInlineStyle, noMedia]) const [refElement, setRefElement] = useState(null) useImperativeHandle(ref as any, () => refElement) const markdownElement = useMemo( () => children && parseHtml(children, { ...remarkOptions, }).toContent(), [children, remarkOptions], ) const { w: containerWidth } = useWrappedElementSize() if (!markdownElement) return null return ( {katexStyle} {createElement( as, { ...rest, id: ENTRY_CONTENT_RENDER_CONTAINER_ID, ref: setRefElement, }, markdownElement, )} {!!accessory && {accessory}} ) } export const HTML = memo(HTMLImpl) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/Markdown.tsx ================================================ import { cn } from "@follow/utils/utils" import { useMemo, useState } from "react" import type { RemarkOptions } from "~/lib/parse-markdown" import { parseMarkdown } from "~/lib/parse-markdown" import { MarkdownRenderContainerRefContext } from "./context" export const Markdown: Component< { children: string } & Partial > = ({ children, components, className, applyMiddleware }) => { const stableRemarkOptions = useState({ components, applyMiddleware })[0] const markdownElement = useMemo( () => parseMarkdown(children, { ...stableRemarkOptions }).content, [children, stableRemarkOptions], ) const [refElement, setRefElement] = useState(null) return (
{markdownElement}
) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/components/Toc.tsx ================================================ import { useViewport } from "@follow/components/hooks/useViewport.js" import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js" import { nextFrame } from "@follow/utils/dom" import { EventBus } from "@follow/utils/event-bus" import { cn } from "@follow/utils/utils" import * as HoverCard from "@radix-ui/react-hover-card" import { AnimatePresence, m } from "motion/react" import { memo, use, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react" import { COMMAND_ID } from "~/modules/command/commands/id" import { useWrappedElementPosition, useWrappedElementSize, } from "~/providers/wrapped-element-provider" import { MarkdownRenderContainerRefContext } from "../context" import { useScrollTracking, useTocItems } from "./hooks" import type { TocItemProps } from "./TocItem" import { TocItem } from "./TocItem" export interface ITocItem { depth: number title: string anchorId: string index: number $heading: HTMLHeadingElement } export interface TocProps { onItemClick?: (index: number, $el: HTMLElement | null, anchorId: string) => void } const WiderTocStyle = { width: 200, } satisfies React.CSSProperties export interface TocRef { refreshItems: () => void } export const Toc = ({ ref, className, onItemClick, }: ComponentType & { ref?: React.Ref }) => { const markdownElement = use(MarkdownRenderContainerRefContext) const { toc, rootDepth, refreshItems } = useTocItems(markdownElement) const { currentScrollRange, handleScrollTo } = useScrollTracking(toc, { onItemClick, }) useImperativeHandle( ref, useCallback(() => { return { refreshItems, } }, [refreshItems]), ) const renderContentElementPosition = useWrappedElementPosition() const renderContentElementSize = useWrappedElementSize() const entryContentInWideMode = false const shouldShowTitle = useViewport((v) => { if (!entryContentInWideMode) return false const { w } = v const xAxis = renderContentElementPosition.x + renderContentElementSize.w return w - xAxis > WiderTocStyle.width + 50 }) if (toc.length === 0) return null return shouldShowTitle ? ( ) : ( ) } const TocContainer: React.FC = ({ className, toc, rootDepth, currentScrollRange, handleScrollTo, }) => { return (
{toc.map((heading, index) => ( ))}
) } const TocHoverCard: React.FC = ({ className, toc, rootDepth, currentScrollRange, handleScrollTo, }) => { const [hoverShow, setHoverShow] = useState(false) return (
{toc.map((heading, index) => ( ))}
{hoverShow && ( {toc.map((heading, index) => (
  • ))}
    )}
    ) } const MemoedItem = memo((props) => { const { // active, range, ...rest } = props const active = range > 0 const itemRef = useRef(null) useEffect(() => { if (!active) return const $item = itemRef.current if (!$item) return const $container = $item.parentElement if (!$container) return const containerHeight = $container.clientHeight const itemHeight = $item.clientHeight const itemOffsetTop = $item.offsetTop const { scrollTop } = $container const itemTop = itemOffsetTop - scrollTop const itemBottom = itemTop + itemHeight if (itemTop < 0 || itemBottom > containerHeight) { $container.scrollTop = itemOffsetTop - containerHeight / 2 + itemHeight / 2 } }, [active]) return }) MemoedItem.displayName = "MemoedItem" // Types interface TocContainerProps { className?: string toc: ITocItem[] rootDepth: number currentScrollRange: [number, number] handleScrollTo: (i: number, $el: HTMLElement | null, anchorId: string) => void } interface TocHoverCardProps extends TocContainerProps {} ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/components/TocItem.tsx ================================================ import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" import { clsx, cn } from "@follow/utils/utils" import type { FC, MouseEvent } from "react" import { memo, useCallback, useRef } from "react" export interface ITocItem { depth: number title: string anchorId: string index: number $heading: HTMLHeadingElement } export interface TocItemProps { heading: ITocItem // active: boolean rootDepth: number onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void isScrollOut: boolean range: number variant?: "line" | "title-line" } export const TocItem: FC = memo((props) => { const { onClick, heading, isScrollOut, range, variant = "line", rootDepth } = props const { $heading, anchorId, depth, index, title } = heading const $ref = useRef(null) const isTitleLine = variant === "title-line" return ( ) }) const widthMap = { 1: 72 - 6, 2: 60 - 6, 3: 48 - 6, 4: 36 - 6, 5: 24 - 6, 6: 12 - 6, } TocItem.displayName = "TocItem" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/components/hooks.tsx ================================================ import { getViewport } from "@follow/components/hooks/useViewport.js" import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" import { getElementTop } from "@follow/utils/dom" import { springScrollToElement } from "@follow/utils/scroller" import { throttle } from "es-toolkit/compat" import { useStore } from "jotai" import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useEventCallback } from "usehooks-ts" import type { ITocItem, TocProps } from "./Toc" // Hooks export const useTocItems = (markdownElement: HTMLElement | null) => { const queryToCItems = useCallback( (): HTMLHeadingElement[] => Array.from(markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []), [markdownElement], ) const [$headings, setHeadings] = useState(queryToCItems) useEffect(() => { setHeadings(queryToCItems()) }, [markdownElement, queryToCItems]) const toc: ITocItem[] = useMemo( () => Array.from($headings).map((el, idx) => { const depth = +el.tagName.slice(1) const elClone = el.cloneNode(true) as HTMLElement const title = elClone.textContent || "" const index = idx return { depth, index: Number.isNaN(index) ? -1 : index, title, anchorId: el.dataset.rid || "", $heading: el, } }), [$headings], ) const rootDepth = useMemo( () => toc?.length ? (toc.reduce( (d: number, cur) => Math.min(d, cur.depth), toc[0]?.depth || 0, ) as any as number) : 0, [toc], ) return { toc, rootDepth, refreshItems: useCallback(() => { setHeadings(queryToCItems()) }, [queryToCItems]), } } type DebouncedFuncLeading any> = T & { cancel: () => void flush: () => void } export const useScrollTracking = ( toc: ITocItem[], options: Pick & { useWindowScroll?: boolean }, ) => { const _scrollContainerElement = useScrollViewElement() const scrollContainerElement = options.useWindowScroll ? document : _scrollContainerElement const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number]) const headingTopsRef = useRef([]) const [headingTopsVersion, setHeadingTopsVersion] = useState(0) const throttleCallerRef = useRef void>>(undefined) const store = useStore() useLayoutEffect(() => { if (!scrollContainerElement || toc.length === 0) { headingTopsRef.current = [] setHeadingTopsVersion((v) => v + 1) return } const scrollContainerTop = scrollContainerElement === document ? 0 : getElementTop(scrollContainerElement as HTMLElement) const tops = toc.map(({ $heading }) => { const elementTop = getElementTop($heading) const top = elementTop - scrollContainerTop return top }) headingTopsRef.current = tops setHeadingTopsVersion((v) => v + 1) }, [toc, scrollContainerElement]) useEffect(() => { if (!scrollContainerElement || toc.length === 0) return const handler = throttle(() => { const storeViewport = getViewport(store) const winHeight = storeViewport.h const headingTops = headingTopsRef.current if (headingTops.length === 0) return const scrollTop = scrollContainerElement === document ? document.documentElement.scrollTop : (scrollContainerElement as HTMLElement).scrollTop const activationLine = scrollTop + winHeight / 3 let activeIndex = -1 for (let i = headingTops.length - 1; i >= 0; i--) { if (activationLine >= headingTops[i]!) { activeIndex = i break } } if (activeIndex === -1) { setCurrentScrollRange([-1, 0]) } else if (activeIndex === headingTops.length - 1) { const lastHeadingTop = headingTops[activeIndex]! const contentEnd = scrollContainerElement === document ? document.documentElement.scrollHeight : (scrollContainerElement as HTMLElement).scrollHeight const total = contentEnd - lastHeadingTop const current = activationLine - lastHeadingTop const progress = Math.min(1, Math.max(0, total > 0 ? current / total : 0)) setCurrentScrollRange([activeIndex, progress]) } else { const currentHeadingTop = headingTops[activeIndex]! const nextHeadingTop = headingTops[activeIndex + 1]! const total = nextHeadingTop - currentHeadingTop const current = activationLine - currentHeadingTop const progress = Math.min(1, Math.max(0, total > 0 ? current / total : 0)) setCurrentScrollRange([activeIndex, progress]) } }, 100) throttleCallerRef.current = handler handler() scrollContainerElement.addEventListener("scroll", handler, { passive: true }) return () => { scrollContainerElement.removeEventListener("scroll", handler) handler.cancel() } }, [scrollContainerElement, store, toc, headingTopsVersion]) const handleScrollTo = useEventCallback( (i: number, $el: HTMLElement | null, _anchorId: string) => { options.onItemClick?.(i, $el, _anchorId) if ($el && scrollContainerElement) { springScrollToElement( $el, -100, scrollContainerElement === document ? undefined : (scrollContainerElement as HTMLElement), ).then(() => { throttleCallerRef.current?.cancel() setTimeout(() => { throttleCallerRef.current?.() }, 50) }) } }, ) return { currentScrollRange, handleScrollTo } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/context.tsx ================================================ import { createContext as reactCreateContext } from "react" import { createContext } from "use-context-selector" import type { MarkdownImage, MarkdownRenderActions } from "./types" export const MarkdownRenderContainerRefContext = reactCreateContext(null) export const MarkdownImageRecordContext = createContext>({}) export const MarkdownRenderActionContext = reactCreateContext({ transformUrl: (url) => url ?? "", isAudio: () => false, ensureAndRenderTimeStamp: () => false, }) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockErrorBoundary.tsx ================================================ import { tracker } from "@follow/tracker" import { useEffect } from "react" export const BlockError = (props: { error: any; message: string }) => { useEffect(() => { console.error(props.error) void tracker.manager.captureException(props.error, { source: "desktop_markdown_block_error", message: props.message, }) }, [props.error, props.message]) return (
    {props.message}
    {props.error?.message}
    ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.tsx ================================================ import { cn } from "@follow/utils/utils" import { use } from "react" import { useContextSelector } from "use-context-selector" import { useWrappedElementSize } from "~/providers/wrapped-element-provider" import { Media } from "../../media/Media" import { MarkdownImageRecordContext, MarkdownRenderActionContext } from "../context" export const MarkdownBlockImage = ( props: React.ImgHTMLAttributes & { proxy?: { width: number height: number } }, ) => { const size = useWrappedElementSize() const { transformUrl } = use(MarkdownRenderActionContext) const src = transformUrl(props.src) const media = useContextSelector(MarkdownImageRecordContext, (record) => props.src ? record[props.src] : null, ) return ( ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/Heading.tsx ================================================ import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" import { springScrollToElement } from "@follow/utils/scroller" import { cn } from "@follow/utils/utils" import { use, useId, useRef } from "react" import { MarkdownRenderContainerRefContext } from "../context" export const createHeadingRenderer = (level: number) => ( props: React.DetailedHTMLProps, HTMLHeadingElement>, ) => { const rid = useId() const As = `h${level}` as any const { node, ...rest } = props as any const scroller = useScrollViewElement() const renderContainer = use(MarkdownRenderContainerRefContext) const ref = useRef(null) return ( {rest.children} { if (!renderContainer) return springScrollToElement( renderContainer.querySelector(`[data-rid="${rid}"]`)!, -100, scroller!, ) }} > ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/InlineImage.tsx ================================================ import { cn } from "@follow/utils/utils" import { use } from "react" import { useContextSelector } from "use-context-selector" import { Media } from "../../media/Media" import { MarkdownImageRecordContext, MarkdownRenderActionContext } from "../context" export const MarkdownInlineImage = ( props: React.ImgHTMLAttributes & { proxy?: { width: number height: number } }, ) => { const { transformUrl } = use(MarkdownRenderActionContext) const populatedUrl = transformUrl(props.src) const media = useContextSelector(MarkdownImageRecordContext, (record) => props.src ? record[props.src] : null, ) return ( ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/MarkdownLink.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import type { LinkProps } from "@follow/components/ui/link/LinkWithTooltip.js" import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger, } from "@follow/components/ui/tooltip/index.jsx" import { useCorrectZIndex } from "@follow/components/ui/z-index/ctx.js" import { env } from "@follow/shared/env.desktop" import { feedSyncServices } from "@follow/store/feed/store" import { cn, parseSafeUrl, stopPropagation } from "@follow/utils" import type { MouseEvent } from "react" import { use, useCallback } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { copyToClipboard } from "~/lib/clipboard" import { MarkdownRenderActionContext } from "../context" export const MarkdownLink: Component = (props) => { const { transformUrl, isAudio, ensureAndRenderTimeStamp } = use(MarkdownRenderActionContext) const { t } = useTranslation() const populatedFullHref = transformUrl(props.href) const shareFeedInfo = parseShareFeedInfo(populatedFullHref) const handleCopyLink = useCallback(async () => { try { if (!populatedFullHref) { throw new Error("No URL to copy") } await copyToClipboard(populatedFullHref) toast.success(t("share.link_copied")) } catch { toast.error(t("share.copy_failed")) } }, [populatedFullHref, t]) const handleClickLink = useCallback( async (event: MouseEvent) => { stopPropagation(event) if (!shareFeedInfo) { return } event.preventDefault() const view = await resolveShareFeedView(shareFeedInfo) navigateEntry({ feedId: shareFeedInfo.id, entryId: null, view, }) }, [shareFeedInfo], ) const parseTimeStamp = isAudio(populatedFullHref) const zIndex = useCorrectZIndex(0) if (parseTimeStamp) { const childrenText = props.children if (typeof childrenText === "string") { const renderer = ensureAndRenderTimeStamp(childrenText) if (renderer) return renderer } } return (
    {props.children} {typeof props.children === "string" && ( )} {!!populatedFullHref && ( {populatedFullHref} )} ) } const parseShareFeedInfo = (href?: string) => { if (!href) return null const baseUrl = parseSafeUrl(env.VITE_WEB_URL) if (!baseUrl) return null let parsedUrl: URL try { parsedUrl = new URL(href, baseUrl) } catch { return null } if (parsedUrl.host !== baseUrl.host) return null const pathParts = parsedUrl.pathname.split("/").filter(Boolean) if (pathParts.length !== 3 || pathParts[0] !== "share" || pathParts[1] !== "feeds") { return null } const viewParam = parsedUrl.searchParams.get("view") const view = viewParam ? Number.parseInt(viewParam, 10) : undefined return { id: pathParts[2]!, view: Number.isNaN(view) ? undefined : view, } } const resolveShareFeedView = async (info: { id: string; view?: number }) => { if (typeof info.view === "number") { return info.view } const data = await feedSyncServices.fetchFeedById({ id: info.id }).catch(() => {}) const analyticsView = data?.analytics?.view if (typeof analyticsView === "number") { return analyticsView } return 0 } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/MarkdownP.tsx ================================================ import * as React from "react" import { MarkdownRenderActionContext } from "../context" import { IsInParagraphContext } from "./ctx" export const MarkdownP: Component< React.DetailedHTMLProps, HTMLParagraphElement> > = ({ children, ...props }) => { const { isAudio, ensureAndRenderTimeStamp } = React.use(MarkdownRenderActionContext) const parseTimeline = isAudio() if (parseTimeline && typeof children === "string") { const renderer = ensureAndRenderTimeStamp(children) if (renderer) return

    {renderer}

    } if (parseTimeline && Array.isArray(children)) { return (

    {children.map((child, index) => { if (typeof child === "string") { const renderer = ensureAndRenderTimeStamp(child) if (renderer) return {renderer} } return {child} })}

    ) } return (

    {children}

    ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/ctx.tsx ================================================ import { createContext, use } from "react" /** * @internal */ export const IsInParagraphContext = createContext(false) export const useIsInParagraphContext = () => { return use(IsInParagraphContext) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/renderers/index.ts ================================================ export * from "./BlockImage" export * from "./MarkdownLink" export * from "./MarkdownP" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/markdown/types.ts ================================================ export type MarkdownImage = { url: string width?: number | undefined height?: number | undefined preview_image_url?: string | undefined blurhash?: string | undefined } export interface MarkdownRenderActions { transformUrl: (url?: string) => string | undefined isAudio: (url?: string) => boolean ensureAndRenderTimeStamp: (children: string) => React.ReactNode } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/Media.tsx ================================================ import { nextFrame } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import { useForceUpdate } from "motion/react" import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react" import * as React from "react" import { memo, use, useEffect, useMemo, useRef, useState } from "react" import { Blurhash, BlurhashCanvas } from "react-blurhash" import { useEventCallback } from "usehooks-ts" import { useGetImageProxyUrl } from "~/lib/img-proxy" import { saveImageDimensionsToDb } from "~/store/image/db" import { ErrorBoundary } from "../../common/ErrorBoundary" import { useMediaContainerWidth, usePreviewMedia } from "./hooks" import { MediaInfoRecordContext } from "./MediaInfoRecordContext" import type { VideoPlayerRef } from "./VideoPlayer" import { VideoPlayer } from "./VideoPlayer" type BaseProps = { mediaContainerClassName?: string showFallback?: boolean thumbnail?: boolean blurhash?: string inline?: boolean fitContent?: boolean fitContainer?: boolean videoClassName?: string } const isImageLoadedSet = new Set() export type MediaProps = BaseProps & ( | (ImgHTMLAttributes & { proxy?: { width: number height: number } preferOrigin?: boolean popper?: boolean type: "photo" previewImageUrl?: string cacheDimensions?: boolean }) | (VideoHTMLAttributes & { proxy?: { width: number height: number } preferOrigin?: boolean popper?: boolean type: "video" previewImageUrl?: string }) ) const MediaImpl: FC = ({ className, proxy, preferOrigin, popper = false, mediaContainerClassName, thumbnail, ...props }) => { const { src, style, type, previewImageUrl, showFallback, blurhash, height, width, inline, fitContent, fitContainer, videoClassName, ...rest } = props const getImageProxyUrl = useGetImageProxyUrl() const ctxMediaInfo = use(MediaInfoRecordContext) const ctxHeight = ctxMediaInfo[src!]?.height const ctxWidth = ctxMediaInfo[src!]?.width const finalHeight = height || ctxHeight const finalWidth = width || ctxWidth // Define the list of available image sources, sorted by priority const imageSources = useMemo(() => { if (!src) return [] const sources: Array<{ url: string; type: "proxy" | "origin" }> = [] // Determine priority based on preferences if (proxy && !preferOrigin) { // Use proxy first sources.push( { url: getImageProxyUrl({ url: src, width: proxy.width || 0, height: proxy.height || 0, }), type: "proxy", }, { url: src, type: "origin" }, ) } else { // Use original URL first sources.push({ url: src, type: "origin" }) if (proxy) { sources.push({ url: getImageProxyUrl({ url: src, width: proxy.width || 0, height: proxy.height || 0, }), type: "proxy", }) } } return sources }, [src, proxy, preferOrigin, getImageProxyUrl]) const [currentSourceIndex, setCurrentSourceIndex] = useState(0) const [isError, setIsError] = useState(false) const [mediaLoadState, setMediaLoadState] = useState<"loading" | "loaded">("loading") const currentSource = imageSources[currentSourceIndex] const imgSrc = currentSource?.url || src const previewImageSrc = useMemo(() => { if (!previewImageUrl) return // Use the same proxy strategy for preview images if (proxy && currentSource?.type === "proxy") { return getImageProxyUrl({ url: previewImageUrl, width: proxy.width || 0, height: proxy.height || 0, }) } return previewImageUrl }, [previewImageUrl, proxy, currentSource?.type, getImageProxyUrl]) // When image source list changes, reset to the first source const prevImageSources = useRef(imageSources) useEffect(() => { if (prevImageSources.current !== imageSources && imageSources.length > 0) { prevImageSources.current = imageSources setCurrentSourceIndex(0) setIsError(false) } }, [imageSources]) // When image source changes, reset loading state const prevImgSrc = useRef(imgSrc) useEffect(() => { if (prevImgSrc.current !== imgSrc) { prevImgSrc.current = imgSrc setMediaLoadState(imgSrc && isImageLoadedSet.has(imgSrc) ? "loaded" : "loading") } }, [imgSrc]) const errorHandle: React.ReactEventHandler = useEventCallback((e) => { const nextIndex = currentSourceIndex + 1 if (import.meta.env.DEV) { console.info( `[Media Error] Failed to load image source ${currentSourceIndex + 1}/${imageSources.length}`, { failedSrc: imgSrc, originalSrc: src, error: e, willRetry: nextIndex < imageSources.length, nextSource: imageSources[nextIndex]?.url, }, ) } if (nextIndex < imageSources.length) { // Try next available image source setCurrentSourceIndex(nextIndex) setMediaLoadState("loading") } else { // All sources failed, mark as error state setIsError(true) if (import.meta.env.DEV) { console.error(`[Media Error] All image sources failed for: ${src}`, { allSources: imageSources, originalSrc: src, }) } } }) const previewMedia = usePreviewMedia() const handleClick = useEventCallback((e: React.MouseEvent) => { e.preventDefault() if (popper && src) { const width = Number.parseInt(props.width as string) const height = Number.parseInt(props.height as string) previewMedia( [ { url: src, type, fallbackUrl: imgSrc, blurhash: props.blurhash, width: width || undefined, height: height || undefined, }, ], 0, ) } props.onClick?.(e as any) }) const handleOnLoad: React.ReactEventHandler = useEventCallback((e) => { setMediaLoadState("loaded") rest.onLoad?.(e as any) if (import.meta.env.DEV) { console.info(`[Media Success] Image loaded successfully`, { src: imgSrc, originalSrc: src, sourceType: currentSource?.type, sourceIndex: currentSourceIndex + 1, totalSources: imageSources.length, dimensions: { width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight, ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight, }, loadTime: performance.now(), }) } if (imgSrc) { isImageLoadedSet.add(imgSrc) } if ("cacheDimensions" in props && props.cacheDimensions && src) { saveImageDimensionsToDb(src, { src, width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight, ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight, blurhash: props.blurhash, }) } }) const containerWidth = useMediaContainerWidth() const InnerContent = useMemo(() => { switch (type) { case "photo": { // @ts-expect-error const { cacheDimensions, ...props } = rest return ( )} onError={errorHandle} className={cn( "size-full object-contain", inline && "inline size-auto align-sub", popper && "cursor-zoom-in", "duration-200", mediaLoadState === "loaded" ? "opacity-100" : "opacity-0", "!my-0", mediaContainerClassName, )} src={imgSrc} onLoad={handleOnLoad} onClick={handleClick} /> ) } case "video": { return ( ) } default: { return null } } }, [ type, rest, finalHeight, finalWidth, errorHandle, inline, popper, mediaLoadState, mediaContainerClassName, imgSrc, handleOnLoad, handleClick, src, previewImageSrc, thumbnail, videoClassName, ]) if (!type || !src) return null if (isError) { if (showFallback) { return ( ) } else { return (
    {props.blurhash && ( )}
    ) } } return ( {!!props.width && !!props.height && !!containerWidth ? (
    {blurhash ? ( ) : (
    )}
    {InnerContent}
    ) : ( InnerContent )} ) } export const Media: FC = memo((props) => ) const FallbackMedia: FC = ({ type, mediaContainerClassName, className, ...props }) => (

    Media loaded failed

    ) const AspectRatio = ({ width, height, containerWidth, children, style, fitContent, fitContainer, ...props }: { width: number height: number containerWidth?: number children: React.ReactNode style?: React.CSSProperties /** * If `fit` is true, the content width may be increased to fit the container width */ fitContent?: boolean fitContainer?: boolean [key: string]: any }) => { const scaleFactor = containerWidth && width ? fitContent ? containerWidth / width : Math.min(1, containerWidth / width) : 1 const scaledWidth = width ? width * scaleFactor : undefined const scaledHeight = height ? height * scaleFactor : undefined return (
    {children}
    ) } const VideoPreview: FC<{ src: string previewImageUrl?: string thumbnail?: boolean videoClassName?: string }> = ({ src, previewImageUrl, thumbnail = false, videoClassName }) => { const [isInitVideoPlayer, setIsInitVideoPlayer] = useState(!previewImageUrl) const [videoRef, setVideoRef] = useState(null) const isPaused = videoRef ? videoRef?.getState().paused : true const [forceUpdate] = useForceUpdate() return (
    { videoRef?.controls.play()?.then(forceUpdate) }} onMouseLeave={() => { videoRef?.controls.pause() nextFrame(forceUpdate) }} > {!isInitVideoPlayer ? ( { setIsInitVideoPlayer(true) }} /> ) : ( )}
    ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/MediaContainerWidthContext.tsx ================================================ import { createContext } from "react" export const MediaContainerWidthContext = createContext(0) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/MediaContainerWidthProvider.tsx ================================================ import { MediaContainerWidthContext } from "./MediaContainerWidthContext" export const MediaContainerWidthProvider = ({ children, width, }: { children: React.ReactNode width: number }) => { return {children} } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/MediaInfoRecord.tsx ================================================ export type MediaInfoRecord = Record ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/MediaInfoRecordContext.tsx ================================================ import { createContext } from "react" import type { MediaInfoRecord } from "./MediaInfoRecord" export const MediaInfoRecordContext = createContext({}) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/MediaInfoRecordProvider.tsx ================================================ import type { MediaInfoRecord } from "./MediaInfoRecord" import { MediaInfoRecordContext } from "./MediaInfoRecordContext" const noop = {} as const export const MediaInfoRecordProvider = ({ children, mediaInfo, }: { children: React.ReactNode mediaInfo?: Nullable }) => { return {children} } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx ================================================ import { Spring } from "@follow/components/constants/spring.js" import { MotionButtonBase } from "@follow/components/ui/button/index.js" import { IN_ELECTRON } from "@follow/shared/constants" import { stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import type { EntryMedia } from "@follow-app/client-sdk" import useEmblaCarousel from "embla-carousel-react" import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures" import { useAnimationControls } from "motion/react" import type { FC } from "react" import * as React from "react" import { Fragment, use, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import type { ReactZoomPanPinchRef, ReactZoomPanPinchState } from "react-zoom-pan-pinch" import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch" import { m } from "~/components/common/Motion" import { GlassButton } from "~/components/ui/button/GlassButton" import { COPY_MAP } from "~/constants" import { ipcServices } from "~/lib/client" import { useReplaceImgUrlIfNeed } from "~/lib/img-proxy" import { useCurrentModal } from "../modal/stacked/hooks" import type { VideoPlayerRef } from "./VideoPlayer" import { VideoPlayer } from "./VideoPlayer" // Calculate the dynamic scale value and offset const calculateDragTransforms = (x: number, y: number) => { // Minimum scale to 0.7, maximum keep 1.0 const maxDistance = 300 const dragDistance = Math.hypot(x, y) const progress = Math.min(dragDistance / maxDistance, 1) const scale = 1 - progress * 0.3 // From 1.0 to 0.7 // Calculate the opacity, minimum to 0.5 const opacity = 1 - progress * 0.5 return { scale, opacity, x, y } } // Framer Motion variants const modalVariants = { initial: { scale: 0.94, opacity: 0 }, visible: { scale: 1, opacity: 1, x: 0, y: 0 }, exit: { scale: 0.94, opacity: 0 }, closing: (dragOffset: { x: number; y: number }) => ({ scale: 0.3, x: dragOffset.x, y: dragOffset.y, opacity: 0, }), } const PreviewWrapperDragContext = React.createContext<{ isDragging: boolean lastDragEndAt: number }>({ isDragging: false, lastDragEndAt: 0 }) const Wrapper: FC<{ src: string children: | [React.ReactNode, React.ReactNode | undefined] | React.ReactNode | (( onZoomChange: (isZoomed: boolean) => void, ) => [React.ReactNode, React.ReactNode | undefined] | React.ReactNode) className?: string onZoomChange?: (isZoomed: boolean) => void canDragClose?: boolean }> = ({ children, src, onZoomChange, canDragClose = true }) => { const containerRef = useRef(null) const { dismiss } = useCurrentModal() const controls = useAnimationControls() // Drag close state const [isImageZoomed, setIsImageZoomed] = useState(false) const [isDragging, setIsDragging] = useState(false) const [lastDragEndAt, setLastDragEndAt] = useState(0) const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) // Combined zoom change callback const handleZoomChange = useCallback( (isZoomed: boolean) => { setIsImageZoomed(isZoomed) onZoomChange?.(isZoomed) }, [onZoomChange], ) const renderedChildren = typeof children === "function" ? children(handleZoomChange) : children const isArray = Array.isArray(renderedChildren) const hasSideContent = isArray && !!renderedChildren[1] const enableDragClose = !isImageZoomed && canDragClose const handleDrag = useCallback( (_: any, info: any) => { if (!isDragging) return const { offset } = info setDragOffset(offset) // Real-time update the transform when dragging const dragTransforms = calculateDragTransforms(offset.x, offset.y) controls.set({ scale: dragTransforms.scale, x: offset.x * 0.3, y: offset.y * 0.3, opacity: dragTransforms.opacity, }) }, [isDragging, controls], ) const handleDragEnd = useCallback( async (_: any, info: any) => { const { offset, velocity } = info // Calculate the drag distance and velocity const dragDistance = Math.hypot(offset.x, offset.y) const velocityDistance = Math.hypot(velocity.x, velocity.y) // 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 const shouldClose = offset.y > 100 || dragDistance > 150 || velocity.y > 300 || velocityDistance > 500 if (shouldClose) { // Execute the closing animation await controls.start("closing", { type: "spring", stiffness: 400, damping: 40, duration: 0.3, }) dismiss() } else { // Reset to normal state setIsDragging(false) setLastDragEndAt(performance.now()) setDragOffset({ x: 0, y: 0 }) controls.start("visible", { ...Spring.presets.snappy, }) } }, [controls, dismiss], ) const handleDragStart = useCallback(() => { setIsDragging(true) }, []) // Initialize the animation useEffect(() => { controls.start("visible", { ...Spring.presets.snappy, }) }, [controls]) const dragCtxValue = useMemo(() => ({ isDragging, lastDragEndAt }), [isDragging, lastDragEndAt]) return (
    {isArray ? renderedChildren[0] : renderedChildren}
    {hasSideContent ? (
    {isArray ? renderedChildren[1] : null}
    ) : undefined}
    ) } const headerActionsVariants = { initial: { opacity: 0, translateY: "-20px" }, animate: { opacity: 1, translateY: "0px" }, exit: { opacity: 0, translateY: "-20px" }, } const GLASS_BUTTON_CLASS = tw`group-hover/left:opacity-100 opacity-0` const HeaderActions: FC<{ src: string }> = ({ src }) => { const { t } = useTranslation(["shortcuts", "common"]) const { dismiss } = useCurrentModal() return ( window.open(src)} > {IN_ELECTRON && ( { ipcServices?.app.download(src) }} > )} ) } export interface PreviewMediaProps extends EntryMedia { fallbackUrl?: string } export const PreviewMediaContent: FC<{ media: PreviewMediaProps[] initialIndex?: number children?: React.ReactNode onZoomChange?: (isZoomed: boolean) => void }> = ({ media, initialIndex = 0, children, onZoomChange }) => { const videoRefs = useRef<(VideoPlayerRef | null)[]>([]) const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [ WheelGesturesPlugin(), ]) const [currentMedia, setCurrentMedia] = useState(media[initialIndex]) // This only to delay show const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex) useEffect(() => { if (emblaApi) { emblaApi.on("select", () => { const realIndex = emblaApi.selectedScrollSnap() setCurrentMedia(media[realIndex]) setCurrentSlideIndex(realIndex) }) } }, [emblaApi, media]) const { ref } = useCurrentModal() // Keyboard useEffect(() => { if (!emblaApi) return const $container = ref.current if (!$container) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") emblaApi?.scrollPrev() if (e.key === "ArrowRight") emblaApi?.scrollNext() } $container.addEventListener("keydown", handleKeyDown) return () => $container.removeEventListener("keydown", handleKeyDown) }, [emblaApi, ref]) const setVideoRef = useCallback((el: VideoPlayerRef | null, index: number) => { videoRefs.current[index] = el }, []) // Pause all videos when slide change // And play the current video if it's a video useEffect(() => { videoRefs.current.forEach((video) => { video?.controls.pause() }) const currentVideo = videoRefs.current[currentSlideIndex] if (currentVideo) { currentVideo.controls.play() } }, [currentSlideIndex]) if (media.length === 0) return null if (media.length === 1) { const src = media[0]!.url! const { type } = media[0]! const isVideo = type === "video" return ( {(handleZoomChange) => [ {isVideo ? ( ) : ( )} , children, ]} ) } return ( {(handleZoomChange) => [
    {media.map((med, i) => (
    {med.type === "video" ? ( setVideoRef(el, i)} src={med.url} muted controls className="size-full object-contain" onClick={(e) => e.stopPropagation()} /> ) : ( )}
    ))}
    {currentSlideIndex > 0 && ( { emblaApi?.scrollPrev() }} > )} {currentSlideIndex < media.length - 1 && ( { emblaApi?.scrollNext() }} > )}
    , children, ]}
    ) } const FallbackableImage: FC< Omit, "src"> & { src: string containerClassName?: string fallbackUrl?: string blurhash?: string onZoomChange?: (isZoomed: boolean) => void } > = ({ src, fallbackUrl, containerClassName, onZoomChange, loading }) => { const replaceImgUrlIfNeed = useReplaceImgUrlIfNeed() const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src)) const [isAllError, setIsAllError] = useState(false) const [isLoading, setIsLoading] = useState(true) const [currentState, setCurrentState] = useState<"proxy" | "origin" | "fallback">(() => currentSrc === src ? "origin" : "proxy", ) const handleError = useCallback(() => { switch (currentState) { case "proxy": { if (currentSrc !== src) { setCurrentSrc(src) setCurrentState("origin") } else { if (fallbackUrl) { setCurrentSrc(fallbackUrl) setCurrentState("fallback") } } break } case "origin": { if (fallbackUrl) { setCurrentSrc(fallbackUrl) setCurrentState("fallback") } else { setIsAllError(true) } break } case "fallback": { setIsAllError(true) } } }, [currentSrc, currentState, fallbackUrl, src]) return (
    {!isAllError && currentSrc && ( setIsLoading(false)} onError={handleError} onZoomChange={onZoomChange} /> )} {isAllError && (
    Failed to load image
    { setCurrentSrc(replaceImgUrlIfNeed(src)) setIsAllError(false) }} > Retry or Visit Original
    )} {currentState === "fallback" && (
    This image is preview in low quality, because the original image is not available.
    You can{" "} visit the original image {" "} if you want to see the full quality.
    )}
    ) } const DOMImageViewer: FC<{ height?: number width?: number onZoomChange?: (isZoomed: boolean) => any minZoom: number maxZoom: number src: string alt: string highResLoaded: boolean loading?: "lazy" | "eager" onLoad?: () => void onError?: () => void }> = ({ height, width, onZoomChange, minZoom, maxZoom, src, alt, highResLoaded, loading = "eager", onLoad, onError, }) => { const { isDragging: isModalDragging, lastDragEndAt } = use(PreviewWrapperDragContext) const onTransformed = useCallback( (ref: ReactZoomPanPinchRef, state: Omit) => { const isZoomed = state.scale !== 1 onZoomChange?.(isZoomed) }, [onZoomChange], ) const transformRef = useRef(null) useEffect(() => { if (transformRef.current) { transformRef.current.resetTransform() } }, [src]) const { dismiss } = useCurrentModal() return (
    { if ( (e as React.MouseEvent).detail >= 2 || isModalDragging || performance.now() - lastDragEndAt < 150 ) { stopPropagation(e) return } const target = e.target as HTMLElement // If click is not on the image container, treat it as overlay click and dismiss if (!target.closest("[data-image-container]")) { dismiss() return } stopPropagation(e) }, }} wrapperClass="!w-full !h-full !absolute !inset-0 cursor-default" contentClass="!w-full !h-full flex items-center justify-center" >
    {alt}
    ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx ================================================ import type { MediaModel } from "@follow/database/schemas/types" import { stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import useEmblaCarousel from "embla-carousel-react" import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures" import { uniqBy } from "es-toolkit/compat" import { useCallback, useRef } from "react" import { Media } from "~/components/ui/media/Media" const defaultProxySize = { width: 600, height: 0, } export function SwipeMedia({ media, className, imgClassName, onPreview, proxySize = defaultProxySize, fitContainer, }: { media?: MediaModel[] | null className?: string imgClassName?: string onPreview?: (media: MediaModel[], index?: number) => void proxySize?: { width: number height: number } | null fitContainer?: boolean }) { const uniqMedia = media ? uniqBy(media, "url") : [] const hoverRef = useRef(null) const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [WheelGesturesPlugin()]) const scrollPrev = useCallback( (e) => { e.preventDefault() e.stopPropagation() if (emblaApi) emblaApi.scrollPrev() }, [emblaApi], ) const scrollNext = useCallback( (e) => { e.preventDefault() e.stopPropagation() if (emblaApi) emblaApi.scrollNext() }, [emblaApi], ) if (!media) return null return (
    {uniqMedia?.length ? (
    {uniqMedia?.slice(0, 5).map((med, i) => (
    { if (onPreview) { e.stopPropagation() onPreview(uniqMedia, i) } }} showFallback={true} fitContent fitContainer={fitContainer} />
    ))}
    {emblaApi?.canScrollPrev() && ( )} {emblaApi?.canScrollNext() && ( )}
    ) : (
    )}
    ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx ================================================ import { Spring } from "@follow/components/constants/spring.js" import { ActionButton, MotionButtonBase } from "@follow/components/ui/button/index.js" import type { HTMLMediaState } from "@follow/hooks" import { useRefValue, useVideo } from "@follow/hooks" import { nextFrame, stopPropagation } from "@follow/utils/dom" import { clsx, cn } from "@follow/utils/utils" import * as Slider from "@radix-ui/react-slider" import { m, useDragControls, useSpring } from "motion/react" import type { PropsWithChildren, RefObject } from "react" import { memo, startTransition, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react" import { useHotkeys } from "react-hotkeys-hook" import { useTranslation } from "react-i18next" import { createContext, useContext, useContextSelector } from "use-context-selector" import { useEventCallback } from "usehooks-ts" import { AudioPlayer } from "~/atoms/player" import { Focusable } from "~/components/common/Focusable" import { IconScaleTransition } from "~/components/ux/transition/icon" import { HotkeyScope } from "~/constants" import { VolumeSlider } from "./VolumeSlider" type VideoPlayerProps = { src: string variant?: "preview" | "player" | "thumbnail" } & React.VideoHTMLAttributes & PropsWithChildren export type VideoPlayerRef = { getElement: () => HTMLVideoElement | null getState: () => HTMLMediaState controls: { play: () => Promise | undefined pause: () => void seek: (time: number) => void volume: (volume: number) => void mute: () => void unmute: () => void } wrapperRef: RefObject } interface VideoPlayerContextValue { state: HTMLMediaState controls: VideoPlayerRef["controls"] wrapperRef: RefObject src: string variant: "preview" | "player" | "thumbnail" } const VideoPlayerContext = createContext(null!) export const VideoPlayer = ({ ref, src, className, variant = "player", ...rest }: VideoPlayerProps & { ref?: React.Ref | ((ref: VideoPlayerRef) => void) }) => { const isPlayer = variant === "player" const [clickToStatus, setClickToStatus] = useState(null as "play" | "pause" | null) const scaleValue = useSpring(1, Spring.presets.smooth) const opacityValue = useSpring(0, Spring.presets.smooth) const handleClick = useEventCallback((e?: any) => { if (!isPlayer) return e?.stopPropagation() if (state.playing) { controls.pause() setClickToStatus("pause") } else { controls.play() setClickToStatus("play") } opacityValue.jump(1) scaleValue.jump(1) nextFrame(() => { scaleValue.set(1.3) opacityValue.set(0) }) }) const [element, state, controls, videoRef] = useVideo({ src, className, playsInline: true, ...rest, controls: false, onClick(e) { rest.onClick?.(e) handleClick(e) }, muted: isPlayer ? false : true, onDoubleClick(e) { rest.onDoubleClick?.(e) if (!isPlayer) return e.preventDefault() e.stopPropagation() if (!document.fullscreenElement) { wrapperRef.current?.requestFullscreen() } else { document.exitFullscreen() } }, }) useHotkeys("space", (e) => { e.preventDefault() handleClick() }) const stateRef = useRefValue(state) const memoedControls = useState(controls)[0] const wrapperRef = useRef(null) useImperativeHandle( ref, () => ({ getElement: () => videoRef.current, getState: () => stateRef.current, controls: memoedControls, wrapperRef, }), [stateRef, videoRef, memoedControls], ) return ( {element}
    {state.hasAudio && !state.muted && state.playing && } {/* eslint-disable-next-line @eslint-react/no-context-provider */} ({ state, controls, wrapperRef, src, variant }), [state, controls, src, variant], )} > {variant === "preview" && state.hasAudio && } {isPlayer && }
    ) } const BizControlOutsideMedia = () => { const currentAudioPlayerIsPlayRef = useMemo(() => AudioPlayer.get().status === "playing", []) useEffect(() => { if (currentAudioPlayerIsPlayRef) { AudioPlayer.pause() } return () => { if (currentAudioPlayerIsPlayRef) { AudioPlayer.play() } } }, [currentAudioPlayerIsPlayRef]) return null } const FloatMutedButton = () => { // eslint-disable-next-line @eslint-react/no-use-context const ctx = useContext(VideoPlayerContext) const isMuted = ctx.state.muted return ( { e.stopPropagation() if (isMuted) { ctx.controls.unmute() } else { ctx.controls.mute() } }} > ) } const ControlBar = memo(() => { const { t } = useTranslation() const controls = useContextSelector(VideoPlayerContext, (v) => v.controls) const isPaused = useContextSelector(VideoPlayerContext, (v) => v.state.paused) const dragControls = useDragControls() return ( {/* Drag Area */}
    { if (isPaused) { controls.play() } else { controls.pause() } }} > {/* Progress bar */} {/* Right Action */} ) }) const FullScreenControl = () => { const { t } = useTranslation() const ref = useContextSelector(VideoPlayerContext, (v) => v.wrapperRef) const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement) useEffect(() => { const onFullScreenChange = () => { setIsFullScreen(!!document.fullscreenElement) } document.addEventListener("fullscreenchange", onFullScreenChange) return () => { document.removeEventListener("fullscreenchange", onFullScreenChange) } }, []) return ( { if (!ref.current) return if (isFullScreen) { document.exitFullscreen() } else { ref.current.requestFullscreen() } }} > {isFullScreen ? ( ) : ( )} ) } const DownloadVideo = () => { const { t } = useTranslation() const src = useContextSelector(VideoPlayerContext, (v) => v.src) const [isDownloading, setIsDownloading] = useState(false) const download = useEventCallback(() => { setIsDownloading(true) fetch(src) .then((res) => res.blob()) .then((blob) => { const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = src.split("/").pop()! a.click() URL.revokeObjectURL(url) setIsDownloading(false) }) }) return ( {isDownloading ? ( ) : ( )} ) } const VolumeControl = () => { const { t } = useTranslation() const hasAudio = useContextSelector(VideoPlayerContext, (v) => v.state.hasAudio) const controls = useContextSelector(VideoPlayerContext, (v) => v.controls) const volume = useContextSelector(VideoPlayerContext, (v) => v.state.volume) const muted = useContextSelector(VideoPlayerContext, (v) => v.state.muted) if (!hasAudio) return null return ( } enableHoverableContent onClick={() => { if (muted) { controls.unmute() } else { controls.mute() } }} > {muted ? ( ) : ( )} ) } const PlayProgressBar = () => { // eslint-disable-next-line @eslint-react/no-use-context const { state, controls } = useContext(VideoPlayerContext) const [currentDragging, setCurrentDragging] = useState(false) const [dragTime, setDragTime] = useState(0) useHotkeys("left", (e) => { e.preventDefault() controls.seek(state.time - 5) }) useHotkeys("right", (e) => { e.preventDefault() controls.seek(state.time + 5) }) return ( { if (state.playing) { controls.pause() } setDragTime(state.time) setCurrentDragging(true) }} onValueChange={(value) => { setDragTime(value[0]!) startTransition(() => { controls.seek(value[0]!) }) }} onValueCommit={() => { controls.play() setCurrentDragging(false) controls.seek(dragTime) }} > {/* indicator */} ) } const ActionIcon = ({ className, onClick, children, shortcut, label, enableHoverableContent, }: { className?: string onClick?: () => void label: React.ReactNode children?: React.ReactNode shortcut?: string enableHoverableContent?: boolean }) => { return ( {children || } ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/VolumeSlider.tsx ================================================ import * as Slider from "@radix-ui/react-slider" import type { FC } from "react" export const VolumeSlider: FC<{ volume: number onVolumeChange: (volume: number) => void }> = ({ onVolumeChange, volume }) => ( { onVolumeChange?.(values[0]!) }} > {/* indicator */} ) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/media/hooks.tsx ================================================ import { isMobile } from "@follow/components/hooks/useMobile.js" import { use, useCallback } from "react" import { PlainModal } from "../modal/stacked/custom-modal" import { useModalStack } from "../modal/stacked/hooks" import { MediaContainerWidthContext } from "./MediaContainerWidthContext" import type { PreviewMediaProps } from "./PreviewMediaContent" import { PreviewMediaContent } from "./PreviewMediaContent" export const usePreviewMedia = (children?: React.ReactNode) => { const { present } = useModalStack() return useCallback( (media?: PreviewMediaProps[], initialIndex = 0) => { if (!media || media.length === 0) { return } if (isMobile()) { window.open(media[initialIndex]!.url) return } present({ content: () => ( {children} ), autoFocus: false, title: "Media Preview", overlay: false, overlayOptions: { blur: false, className: "bg-transparent", }, CustomModalComponent: PlainModal, clickOutsideToDismiss: false, }) }, [children, present], ) } export const useMediaContainerWidth = () => { return use(MediaContainerWidthContext) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/components/close.tsx ================================================ import { cn } from "@follow/utils" import { useTranslation } from "react-i18next" import { GlassButton } from "~/components/ui/button/GlassButton" export const FixedModalCloseButton: Component<{ onClick: () => void className?: string }> = ({ onClick, className }) => { const { t } = useTranslation("common") return ( ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/helper/useAsyncModal.tsx ================================================ /* eslint-disable react-refresh/only-export-components */ import { useOnce, useTypeScriptHappyCallback } from "@follow/hooks" import type { FC } from "react" import { createContext, createElement, use } from "react" import { useEventCallback } from "usehooks-ts" import type { ModalActionsInternal } from "~/components/ui/modal" import type { UseAsyncFetcher } from "~/components/ui/modal/stacked/AsyncModalContent" import { AsyncModalContent } from "~/components/ui/modal/stacked/AsyncModalContent" import { NoopChildren } from "~/components/ui/modal/stacked/custom-modal" import { useModalStack } from "~/components/ui/modal/stacked/hooks" export type AsyncModalOptions = { id: string title: ((data: T) => string) | string icon?: (data: T) => React.ReactNode useDataFetcher: () => UseAsyncFetcher content: FC // Modal options overlay?: boolean clickOutsideToDismiss?: boolean } const AsyncModalContext = createContext>(null!) export const useAsyncModal = () => { const { present } = useModalStack() return useEventCallback((options: AsyncModalOptions) => { present({ id: options.id, content: () => ( ), title: "Loading...", CustomModalComponent: NoopChildren, overlay: options.overlay, }) }) } const LazyContent = () => { const ctx = use(AsyncModalContext) const queryResult = ctx.useDataFetcher() return ( ( ), [], )} /> ) } const Presentable: FC<{ data: any }> = ({ data }) => { const { present, dismissTop } = useModalStack() const ctx = use(AsyncModalContext) useOnce(() => { dismissTop() present({ id: `presentable-${ctx.id}`, content: (props) => createElement(ctx.content, { data, ...props }), title: typeof ctx.title === "function" ? ctx.title(data) : ctx.title, icon: ctx.icon?.(data), clickOutsideToDismiss: ctx.clickOutsideToDismiss, }) }) return null } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/helper/useModalStackCalculationAndEffect.tsx ================================================ import { useAtomValue } from "jotai" import { useEffect } from "react" import { modalStackAtom } from "../stacked/atom" export const useModalStackCalculationAndEffect = () => { const stack = useAtomValue(modalStackAtom) const topModalIndex = stack.findLastIndex((item) => item.modal) const overlayIndex = stack.findLastIndex((item) => item.overlay || item.modal) const overlayOptions = stack[overlayIndex]?.overlayOptions const hasModalStack = stack.length > 0 const topModalIsNotSetAsAModal = topModalIndex !== stack.length - 1 useEffect(() => { // NOTE: document.body is being used by radix's dismissable, // and using that will cause radix to get the value of `none` as the store value, // and then revert to `none` instead of `auto` after a modal dismiss. document.documentElement.style.pointerEvents = hasModalStack && !topModalIsNotSetAsAModal ? "none" : "auto" document.documentElement.dataset.hasModal = hasModalStack.toString() }, [hasModalStack, topModalIsNotSetAsAModal]) return { overlayOptions, topModalIndex, hasModalStack, topModalIsNotSetAsAModal, } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/index.ts ================================================ export * from "./stacked" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/inspire/InPeekModal.tsx ================================================ import { createContext, use } from "react" export const InPeekModal = createContext(false) InPeekModal.displayName = "InPeekModal" export const useInPeekModal = () => use(InPeekModal) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/inspire/PeekModal.tsx ================================================ import { getStableRouterNavigate } from "@follow/components/atoms/route.js" import { RootPortalContext } from "@follow/components/ui/portal/provider.js" import type { PropsWithChildren, ReactNode } from "react" import { useState } from "react" import { useTranslation } from "react-i18next" import { m } from "~/components/common/Motion" import { GlassButton } from "~/components/ui/button/GlassButton" import { FixedModalCloseButton } from "../components/close" import { useCurrentModal, useModalStack } from "../stacked/hooks" import { InPeekModal } from "./InPeekModal" interface PeekModalProps { to?: string rightActions?: { onClick: () => void label: string icon: ReactNode }[] } export const PeekModal = (props: PropsWithChildren) => { const { dismissAll } = useModalStack() const { to, children } = props const { t } = useTranslation("common") const { dismiss } = useCurrentModal() const [rootRef, setRootRef] = useState(null) return (
    {children} {props.rightActions?.map((action) => ( {action.icon} ))} {!!to && ( { dismissAll() getStableRouterNavigate()?.(to) }} description={t("words.expand")} size="md" variant="flat" > )}
    ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/AsyncModalContent.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import { LoadingCircle } from "@follow/components/ui/loading/index.jsx" import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx" import { stopPropagation } from "@follow/utils/dom" import { m } from "motion/react" import * as React from "react" import { useTranslation } from "react-i18next" import { useCurrentModal } from "~/components/ui/modal/stacked/hooks" import { createErrorToaster } from "~/lib/error-parser" export interface UseAsyncFetcher { data: Nullable error: Nullable isLoading: boolean refetch: () => void } interface AsyncModalContentProps { queryResult: UseAsyncFetcher renderContent: (data: T) => React.ReactNode loadingComponent?: React.ReactNode } function useCountdown(durationInSeconds: number): boolean { const [isFinished, setIsFinished] = React.useState(false) React.useEffect(() => { const timer = setTimeout(() => { setIsFinished(true) }, durationInSeconds * 1000) return () => clearTimeout(timer) }, [durationInSeconds]) return isFinished } export function AsyncModalContent({ queryResult, renderContent, loadingComponent, }: AsyncModalContentProps) { const { data, isLoading, error, refetch } = queryResult const { dismiss } = useCurrentModal() const shouldShowCloseButton = useCountdown(2) React.useEffect(() => { if (error) { createErrorToaster()(error) } }, [error]) const { t } = useTranslation("common") if (isLoading || !data) { return ( loadingComponent || (
    {!!error && ( {t("retry")} )} {(shouldShowCloseButton || !!error) && ( {t("words.close")} )}
    ) ) } if (error) { return null // Error is already handled by the toaster } return renderContent(data) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/atom.ts ================================================ import { atom } from "jotai" import type { ModalProps } from "./types" export const modalStackAtom = atom([] as (ModalProps & { id: string })[]) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/bus.ts ================================================ import { createEventBus } from "@follow/utils/event-bus" export const ModalEventBus = createEventBus<{ DISMISS: ModalDisposeEvent RE_PRESENT: ModalRePresentEvent }>() export type ModalDisposeEvent = { id: string } export type ModalRePresentEvent = { id: string } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/components.tsx ================================================ import { MotionButtonBase } from "@follow/components/ui/button/index.js" import { useTranslation } from "react-i18next" import { useCurrentModal } from "./hooks" export const ModalClose = () => { const { dismiss } = useCurrentModal() const { t } = useTranslation("common") return ( ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/constants.ts ================================================ import { Spring } from "@follow/components/constants/spring.js" import type { MotionProps, TargetAndTransition } from "motion/react" const enterStyle: TargetAndTransition = { opacity: 1, // Draw the modal towards the viewer from depth transformPerspective: 1200, z: 0, } const initialStyle: TargetAndTransition = { opacity: 0, transformPerspective: 1200, z: -48, } export const modalMontionConfig = { initial: initialStyle, animate: enterStyle, exit: { ...initialStyle, transition: Spring.presets.smooth, }, transition: Spring.presets.snappy, } satisfies MotionProps // Radix context menu z-index 999 export const MODAL_STACK_Z_INDEX = 1001 ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/context.tsx ================================================ import type { FC, RefObject } from "react" import { createContext as reactCreateContext } from "react" import { createContext as createContextSelector } from "use-context-selector" import type { ModalProps } from "./types" export type CurrentModalContentProps = ModalActionsInternal & { ref: RefObject modalElementRef: RefObject } const warnNoProvider = () => { if (import.meta.env.DEV) { console.error( "No ModalProvider found, please make sure to wrap your component with ModalProvider", ) } } const defaultCtxValue: CurrentModalContentProps = { dismiss: warnNoProvider, setClickOutSideToDismiss: warnNoProvider, ref: { current: null }, modalElementRef: { current: null }, getIndex: () => 0, } export const CurrentModalContext = reactCreateContext(defaultCtxValue) export const CurrentModalStateContext = createContextSelector<{ isTop: boolean isInModal: boolean }>({ isTop: true, isInModal: false, }) export type ModalContentComponent = FC export type ModalActionsInternal = { dismiss: () => void setClickOutSideToDismiss: (value: boolean) => void getIndex: () => number } type Disposer = () => void type PresentModalContextInternalFn = (props: ModalProps & { id?: string }) => Disposer export const PresentModalContextInternal = reactCreateContext(() => { warnNoProvider() return () => {} }) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/custom-modal.tsx ================================================ import { Spring } from "@follow/components/constants/spring.js" import { nextFrame, stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import { m, useAnimationControls } from "motion/react" import type { FC, PropsWithChildren } from "react" import { useEffect, useState } from "react" import type { JSX } from "react/jsx-runtime" import { ModalClose } from "./components" import { useCurrentModal } from "./hooks" export const PlainModal = ({ children }: PropsWithChildren) => children export const PlainWithAnimationModal = ({ children }: PropsWithChildren) => { return ( {children} ) } export { PlainModal as NoopChildren } type ModalTemplateType = { (props: PropsWithChildren<{ className?: string }>): JSX.Element class: (className: string) => (props: PropsWithChildren<{ className?: string }>) => JSX.Element } export const SlideUpModal: ModalTemplateType = (props) => { const winHeight = useState(() => window.innerHeight)[0] const { dismiss } = useCurrentModal() return (
    { if (definition === "exit") { dismiss() } }} className={cn( "relative flex flex-col items-center overflow-hidden rounded-xl border bg-theme-background p-8 pb-0", "aspect-[7/9] w-[600px] max-w-full shadow lg:max-h-[calc(100vh-10rem)]", "motion-preset-slide-up motion-duration-200 motion-ease-spring-smooth", props.className, )} > {props.children}
    ) } SlideUpModal.class = (className: string) => { return (props: ComponentType) => ( ) } const modalVariant = { enter: { x: 0, opacity: 1, }, initial: { x: 700, opacity: 0.9, }, exit: { x: 750, opacity: 0, }, } export const DrawerModalLayout: FC = ({ children }) => { const { dismiss } = useCurrentModal() const controller = useAnimationControls() useEffect(() => { nextFrame(() => controller.start("enter")) }, [controller]) return (
    { if (definition === "exit") { dismiss() } }} exit="exit" layout="size" className={cn( "flex flex-col items-center overflow-hidden rounded-xl border bg-theme-background p-8 pb-0", "shadow-drawer-to-left w-[60ch] max-w-full", "fixed bottom-4 right-2 safe-inset-top-4", )} > {children}
    ) } export const ScaleModal: ModalTemplateType = (props) => { const { dismiss } = useCurrentModal() return (
    {props.children}
    ) } ScaleModal.class = (className: string) => { return (props: ComponentType) => ( ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/declarative-modal.tsx ================================================ import { cn } from "@follow/utils/utils" import { AnimatePresence } from "motion/react" import type { FC, ReactNode } from "react" import { useCallback, useEffect, useId, useMemo, useState } from "react" import { jotaiStore } from "~/lib/jotai" import { modalStackAtom } from "./atom" import { ModalInternal } from "./modal" import type { ModalProps } from "./types" export interface DeclarativeModalProps extends Omit { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void children?: ReactNode id?: string } const Noop = () => null const DeclarativeModalImpl: FC = ({ open, defaultOpen, onOpenChange, children, ...rest }) => { const index = useMemo(() => jotaiStore.get(modalStackAtom).length, []) const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false) const id = useId() const item = useMemo( () => ({ ...rest, content: Noop, id, open: internalOpen, }), [id, internalOpen, rest], ) const handleOpenChange = useCallback( (open: boolean) => { setInternalOpen(open) onOpenChange?.(open) }, [onOpenChange, setInternalOpen], ) useEffect(() => { if (open !== undefined && open !== internalOpen) { setInternalOpen(open) } }, [open, internalOpen, setInternalOpen]) return ( {internalOpen && ( {children} )} ) } const FooterAction: Component = ({ children, className }) => (
    {children}
    ) export const DeclarativeModal = Object.assign(DeclarativeModalImpl, { FooterAction, }) ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/helper.tsx ================================================ import type { Enable } from "re-resizable" import type { Context, PropsWithChildren } from "react" import { memo, use } from "react" export const InjectContext = (Context: Context) => { const ctxValue = use(Context) return memo(({ children }: PropsWithChildren) => {children}) } export function resizableOnly(...positions: (keyof Enable)[]) { const enable: Enable = { top: false, right: false, bottom: false, left: false, topRight: false, bottomRight: false, bottomLeft: false, topLeft: false, } for (const position of positions) { enable[position] = true } return enable } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx ================================================ import { Button } from "@follow/components/ui/button/index.js" import { atom, useAtomValue } from "jotai" import type { DragControls } from "motion/react" import type { ResizeCallback, ResizeStartCallback } from "re-resizable" import { use, useDeferredValue, useState } from "react" import { flushSync } from "react-dom" import { useTranslation } from "react-i18next" import { useContextSelector } from "use-context-selector" import { useEventCallback } from "usehooks-ts" import { jotaiStore } from "~/lib/jotai" import { modalStackAtom } from "./atom" import { ModalEventBus } from "./bus" import { CurrentModalContext, CurrentModalStateContext, PresentModalContextInternal, } from "./context" import type { DialogInstance, ModalProps } from "./types" export const modalIdToPropsMap = {} as Record export const useModalStack = () => { const present = use(PresentModalContextInternal) return { present, ...actions, } } const actions = { getTopModalStack() { return jotaiStore.get(modalStackAtom).at(-1) }, getModalStackById(id: string) { return jotaiStore.get(modalStackAtom).find((item) => item.id === id) }, dismiss(id: string) { ModalEventBus.dispatch("DISMISS", { id, }) }, dismissTop() { const topModal = actions.getTopModalStack() if (!topModal) return actions.dismiss(topModal.id) }, dismissAll() { const modalStack = jotaiStore.get(modalStackAtom) modalStack.forEach((item) => actions.dismiss(item.id)) }, } export const useCurrentModal = () => use(CurrentModalContext) export const useIsInModal = () => useContextSelector(CurrentModalStateContext, (v) => v.isInModal) export const useResizeableModal = ( modalElementRef: React.RefObject, { enableResizeable, dragControls, }: { enableResizeable: boolean dragControls?: DragControls }, ) => { const [resizeableStyle, setResizeableStyle] = useState({} as React.CSSProperties) const [isResizeable, setIsResizeable] = useState(false) const [preferDragDir, setPreferDragDir] = useState<"x" | "y" | null>(null) const relocateModal = useEventCallback(() => { if (!enableResizeable) return if (isResizeable) return const $modalElement = modalElementRef.current if (!$modalElement) return const rect = $modalElement.getBoundingClientRect() const { x, y } = rect flushSync(() => { setIsResizeable(true) setResizeableStyle({ position: "fixed", top: `${y}px`, left: `${x}px`, }) }) }) const handleResizeStart = useEventCallback(((e, dir) => { if (!enableResizeable) return relocateModal() const hasTop = /top/i.test(dir) const hasLeft = /left/i.test(dir) if (hasTop || hasLeft) { dragControls?.start(e as any) if (hasTop && hasLeft) { setPreferDragDir(null) } else if (hasTop) { setPreferDragDir("y") } else if (hasLeft) { setPreferDragDir("x") } } }) satisfies ResizeStartCallback) const handleResizeStop = useEventCallback((() => { setPreferDragDir(null) }) satisfies ResizeCallback) return { resizeableStyle, isResizeable, relocateModal, handleResizeStart, handleResizeStop, preferDragDir, } } export const useIsTopModal = () => useContextSelector(CurrentModalStateContext, (v) => v.isTop) export const useDialog = (): DialogInstance => { const { present } = useModalStack() const { t } = useTranslation() return { /** * Show a confirmation dialog with different visual variants * @param options.variant - Visual style variant: * - "ask" (default): Standard confirmation dialog * - "warning": Warning dialog with yellow icon and yellow confirm button * - "danger": Danger dialog with red icon and red confirm button */ ask: useEventCallback((options) => { const variant = options.variant || "ask" // Variant-specific configuration const variantConfig = { ask: { icon: null, confirmVariant: "primary" as const, confirmClassName: "", }, warning: { icon: , confirmVariant: "primary" as const, confirmClassName: "bg-yellow-500", }, danger: { icon: , confirmVariant: "primary" as const, confirmClassName: "bg-red-500", }, } const config = variantConfig[variant] return new Promise((resolve) => { present({ title: (
    {config.icon} {options.title}
    ), content: ({ dismiss }) => (
    {options.message}
    ), canClose: true, clickOutsideToDismiss: false, }) }) }), } } const modalStackLengthAtom = atom((get) => get(modalStackAtom).length) export const useHasModal = () => { // The keydown event of modal exit is triggered in the same loop, // leading to unexpected simultaneous responses to other hotkeys, // so deferredValue is added to delay the update return useDeferredValue(useAtomValue(modalStackLengthAtom) > 0) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/index.ts ================================================ export * from "./context" export * from "./helper" // NOTE: This one can easily cause a circular dependency // export * from "./hooks" export * from "./modal" export * from "./provider" export * from "./types" ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-animate.ts ================================================ import { Spring } from "@follow/components/constants/spring.js" import { nextFrame } from "@follow/utils/dom" import { useAnimationControls } from "motion/react" import { useCallback, useEffect, useLayoutEffect, useState } from "react" import { useEventCallback } from "usehooks-ts" import { ModalEventBus } from "../bus" import { modalMontionConfig } from "../constants" export interface ModalAnimateControls { animateController: ReturnType playNoticeAnimation: () => void playExitAnimation: () => Promise isClosing: boolean readyToClose: () => void } /** * @internal * Hook for managing modal animations including enter, notice, and exit animations */ export const useModalAnimate = (isTop: boolean, modalId: string): ModalAnimateControls => { const animateController = useAnimationControls() const [isClosing, setIsClosing] = useState(false) // Initial enter animation useEffect(() => { ModalEventBus.subscribe("RE_PRESENT", (data) => { if (data.id !== modalId) { return } setIsClosing(false) animateController.start(modalMontionConfig.animate) }) nextFrame(() => { animateController.start(modalMontionConfig.animate) }) }, [animateController, modalId, setIsClosing]) // Notice animation for when modal can't be dismissed const playNoticeAnimation = useCallback(() => { animateController .start({ z: 6, transition: Spring.snappy(0.06), }) .then(() => { animateController.start({ z: 0, }) }) }, [animateController]) // Stack position animation useLayoutEffect(() => { if (isTop) return animateController.start({ z: -64, rotateX: 2.5, y: 8, }) return () => { try { animateController.stop() animateController.start({ z: 0, rotateX: 0, y: 0, }) } catch { /* empty */ } } }, [isTop, animateController]) // Exit animation const playExitAnimation = useEventCallback(async () => { await animateController.start(modalMontionConfig.exit) }) return { animateController, playNoticeAnimation, playExitAnimation, isClosing, readyToClose: useEventCallback(() => { if (isClosing) return // Prevent multiple calls setIsClosing(true) }), } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-drag.ts ================================================ import { useDragControls } from "motion/react" import type { PointerEventHandler, RefObject } from "react" import { useCallback } from "react" import { useResizeableModal } from "../hooks" /** * @internal */ export const useModalResizeAndDrag = ( modalElementRef: RefObject, { resizeable, draggable, }: { resizeable: boolean draggable: boolean }, ) => { const dragController = useDragControls() const { handleResizeStop, handleResizeStart, relocateModal, preferDragDir, isResizeable, resizeableStyle, } = useResizeableModal(modalElementRef, { enableResizeable: resizeable, dragControls: dragController, }) const handleDrag: PointerEventHandler = useCallback( (e) => { if (draggable) { dragController.start(e) } }, [dragController, draggable], ) return { handleDrag, handleResizeStart, handleResizeStop, relocateModal, preferDragDir, isResizeable, resizeableStyle, dragController, } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-select.ts ================================================ import { nextFrame } from "@follow/utils/dom" import { useCallback, useRef } from "react" /** * @internal * * Handle select text in modal */ export const useModalSelect = () => { const isSelectingRef = useRef(false) const handleSelectStart = useCallback(() => { isSelectingRef.current = true }, []) const handleDetectSelectEnd = useCallback(() => { nextFrame(() => { if (isSelectingRef.current) { isSelectingRef.current = false } }) }, []) return { isSelectingRef, handleSelectStart, handleDetectSelectEnd, } } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-subscriber.ts ================================================ import { useEffect } from "react" import { ModalEventBus } from "../bus" import type { ModalActionsInternal } from "../context" /** @internal */ export const useModalSubscriber = (id: string, ctx: ModalActionsInternal) => { useEffect(() => { return ModalEventBus.subscribe("DISMISS", (data) => { if (data.id === id) { ctx.dismiss() } }) }, [ctx, id]) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal-stack.tsx ================================================ import { useAtomValue } from "jotai" import { AnimatePresence } from "motion/react" import { useModalStackCalculationAndEffect } from "../helper/useModalStackCalculationAndEffect" import { modalStackAtom } from "./atom" import { useModalStack } from "./hooks" import { ModalInternal } from "./modal" export const ModalStack = () => { const { present } = useModalStack() window.presentModal = present const stack = useAtomValue(modalStackAtom) const { topModalIndex, overlayOptions } = useModalStackCalculationAndEffect() return ( {stack.map((item, index) => ( ))} ) } ================================================ FILE: apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx ================================================ import { RootPortalContext } from "@follow/components/ui/portal/provider.js" import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" import { ZIndexProvider } from "@follow/components/ui/z-index/index.js" import { useRefValue } from "@follow/hooks" import { ELECTRON_BUILD } from "@follow/shared/constants" import { preventDefault, stopPropagation } from "@follow/utils/dom" import { cn, getOS } from "@follow/utils/utils" import * as Dialog from "@radix-ui/react-dialog" import { produce } from "immer" import { useAtomValue, useSetAtom } from "jotai" import { selectAtom } from "jotai/utils" import type { BoundingBox } from "motion/react" import { Resizable } from "re-resizable" import type { FC, PropsWithChildren, SyntheticEvent } from "react" import { createElement, Fragment, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react" import { useEventCallback } from "usehooks-ts" import { useUISettingKey } from "~/atoms/settings/ui" import { AppErrorBoundary } from "~/components/common/AppErrorBoundary" import { Focusable } from "~/components/common/Focusable" import { SafeFragment } from "~/components/common/Fragment" import { m } from "~/components/common/Motion" import { ErrorComponentType } from "~/components/errors/enum" import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, HotkeyScope } from "~/constants" import { modalStackAtom } from "./atom" import { MODAL_STACK_Z_INDEX, modalMontionConfig } from "./constants" import type { CurrentModalContentProps, ModalActionsInternal } from "./context" import { CurrentModalContext, CurrentModalStateContext } from "./context" import { useModalAnimate } from "./internal/use-animate" import { useModalResizeAndDrag } from "./internal/use-drag" import { useModalSelect } from "./internal/use-select" import { useModalSubscriber } from "./internal/use-subscriber" import { ModalOverlay } from "./overlay" import type { ModalOverlayOptions, ModalProps } from "./types" const DragBar = ELECTRON_BUILD ? ( ) : null export const ModalInternal = memo(function Modal({ ref, item, overlayOptions, onClose: onPropsClose, children, isTop, index, isBottom, }: { item: ModalProps & { id: string } index: number isTop?: boolean isBottom?: boolean overlayOptions?: ModalOverlayOptions onClose?: (open: boolean) => void } & PropsWithChildren & { ref?: React.Ref }) { const { CustomModalComponent, content, title, clickOutsideToDismiss, modalClassName, modalContainerClassName, modalContentClassName, wrapper: Wrapper = Fragment, max, icon, canClose = true, draggable = false, resizeable = false, resizeDefaultSize, modal = true, autoFocus = true, } = item const setStack = useSetAtom(modalStackAtom) // Animation controls const { animateController, playNoticeAnimation, playExitAnimation, isClosing, readyToClose } = useModalAnimate(!!isTop, item.id) // Simple dismiss logic const close = useEventCallback(async (forceClose = false) => { if (!canClose && !forceClose) return readyToClose() try { if (CustomModalComponent) { // Custom modals handle their own animation setStack((p) => p.filter((modal) => modal.id !== item.id)) } else { // Play exit animation then remove from stack\ await playExitAnimation() setStack((p) => p.filter((modal) => modal.id !== item.id)) } } catch (error) { // If animation fails, still remove from stack console.warn("Modal animation failed:", error) setStack((p) => p.filter((modal) => modal.id !== item.id)) } item.onClose?.() onPropsClose?.(false) }) const onClose = useCallback( (open: boolean): void => { if (!open) { close() } }, [close], ) const modalSettingOverlay = useUISettingKey("modalOverlay") const dismiss = useCallback( (e: SyntheticEvent) => { e.stopPropagation() close(true) }, [close], ) const modalElementRef = useRef(null) const { handleDrag, handleResizeStart, handleResizeStop, relocateModal, preferDragDir, isResizeable, resizeableStyle, dragController, } = useModalResizeAndDrag(modalElementRef, { resizeable, draggable, }) const getIndex = useEventCallback(() => index) const [modalContentRef, setModalContentRef] = useState(null) const ModalProps: ModalActionsInternal = useMemo( () => ({ dismiss: close, getIndex, setClickOutSideToDismiss: (v) => { setStack((state) => produce(state, (draft) => { const model = draft.find((modal) => modal.id === item.id) if (!model) return if (model.clickOutsideToDismiss === v) return model.clickOutsideToDismiss = v }), ) }, }), [close, getIndex, item.id, setStack], ) useModalSubscriber(item.id, ModalProps) const ModalContextProps = useMemo( () => ({ ...ModalProps, ref: { current: modalContentRef }, modalElementRef, }), [ModalProps, modalContentRef], ) const [edgeElementRef, setEdgeElementRef] = useState(null) const finalChildren = useMemo( () => ( {children ?? createElement(content, ModalProps)} ), [ModalProps, children, content, edgeElementRef], ) useEffect(() => { if (isClosing) { // Radix dialog will block pointer events document.body.style.pointerEvents = "auto" } }, [isClosing]) const modalStyle = resizeableStyle const { handleSelectStart, handleDetectSelectEnd, isSelectingRef } = useModalSelect() const handleClickOutsideToDismiss = useCallback( (e: SyntheticEvent) => { if (isSelectingRef.current) return if (modal && clickOutsideToDismiss && canClose) { dismiss(e) } else if (modal) { playNoticeAnimation() } }, [canClose, clickOutsideToDismiss, dismiss, modal, playNoticeAnimation, isSelectingRef], ) const openAutoFocus = useCallback( (event: Event) => { if (!autoFocus) { event.preventDefault() } }, [autoFocus], ) const measureDragConstraints = useRef((constraints: BoundingBox) => { if (getOS() === "Windows") { return { ...constraints, top: constraints.top + ElECTRON_CUSTOM_TITLEBAR_HEIGHT, } } return constraints }).current useImperativeHandle(ref, () => modalElementRef.current!) const currentModalZIndex = MODAL_STACK_Z_INDEX + index * 2 const Overlay = (