diff --git a/.cursor/rules/color.mdc b/.cursor/rules/color.mdc new file mode 100644 index 0000000000..c14780fb22 --- /dev/null +++ b/.cursor/rules/color.mdc @@ -0,0 +1,191 @@ +--- +description: +globs: +alwaysApply: false +--- +# UIKit Colors for Tailwind CSS + +You should use @https://github.com/Innei/apple-uikit-colors/blob/main/packages/uikit-colors/macos.ts TailwindCSS atom classname. + +## System Colors +red +orange +yellow +green +mint +teal +cyan +blue +indigo +purple +pink +brown +gray + +## Fill Colors +fill +fill-secondary +fill-tertiary +fill-quaternary +fill-quinary +fill-vibrant +fill-vibrant-secondary +fill-vibrant-tertiary +fill-vibrant-quaternary +fill-vibrant-quinary + +## Text Colors +text +text-secondary +text-tertiary +text-quaternary +text-quinary +text-vibrant +text-vibrant-secondary +text-vibrant-tertiary +text-vibrant-quaternary +text-vibrant-quinary + +## Material Colors +material-ultra-thick +material-thick +material-medium +material-thin +material-ultra-thin +material-opaque + +## Control Colors +control-enabled +control-disabled + +## Interface Colors +menu +popover +titlebar +sidebar +selection-focused +selection-focused-fill +selection-unfocused +selection-unfocused-fill +header-view +tooltip +under-window-background + + +## Applied Colors +All above tailwind atom will match this colors. + +``` +@media (prefers-color-scheme: light) { + html { + --color-red: 255 69 58; + --color-orange: 255 149 0; + --color-yellow: 255 204 0; + --color-green: 40 205 65; + --color-mint: 0 199 190; + --color-teal: 89 173 196; + --color-cyan: 85 190 240; + --color-blue: 0 122 255; + --color-indigo: 88 86 214; + --color-purple: 175 82 222; + --color-pink: 255 45 85; + --color-brown: 162 132 94; + --color-gray: 142 142 147; + --color-fill: 0 0 0 / 0.1; + --color-fillSecondary: 0 0 0 / 0.08; + --color-fillTertiary: 0 0 0 / 0.05; + --color-fillQuaternary: 0 0 0 / 0.03; + --color-fillQuinary: 0 0 0 / 0.02; + --color-fillVibrant: 217 217 217; + --color-fillVibrantSecondary: 230 230 230; + --color-fillVibrantTertiary: 242 242 242; + --color-fillVibrantQuaternary: 247 247 247; + --color-fillVibrantQuinary: 251 251 251; + --color-text: 0 0 0 / 0.85; + --color-textSecondary: 0 0 0 / 0.5; + --color-textTertiary: 0 0 0 / 0.25; + --color-textQuaternary: 0 0 0 / 0.1; + --color-textQuinary: 0 0 0 / 0.05; + --color-textVibrant: 76 76 76; + --color-textVibrantSecondary: 128 128 128; + --color-textVibrantTertiary: 191 191 191; + --color-textVibrantQuaternary: 230 230 230; + --color-textVibrantQuinary: 242 242 242; + --color-materialUltraThick: 246 246 246 / 0.84; + --color-materialThick: 246 246 246 / 0.72; + --color-materialMedium: 246 246 246 / 0.6; + --color-materialThin: 246 246 246 / 0.48; + --color-materialUltraThin: 246 246 246 / 0.36; + --color-materialOpaque: 246 246 246; + --color-controlEnabled: 251 251 251; + --color-controlDisabled: 243 243 243; + --color-menu: 40 40 40 / 0.58; + --color-popover: 0 0 0 / 0.28; + --color-titlebar: 234 234 234 / 0.8; + --color-sidebar: 234 234 234 / 0.84; + --color-selectionFocused: 10 130 255 / 0.75; + --color-selectionFocusedFill: 10 130 255; + --color-selectionUnfocused: 0 0 0 / 0.1; + --color-selectionUnfocusedFill: 246 246 246 / 0.84; + --color-headerView: 255 255 255 / 0.8; + --color-tooltip: 246 246 246 / 0.6; + --color-underWindowBackground: 246 246 246 / 0.84; + } +} +@media (prefers-color-scheme: dark) { + html { + --color-red: 255 69 58; + --color-orange: 255 159 10; + --color-yellow: 255 214 10; + --color-green: 50 215 75; + --color-mint: 106 196 220; + --color-teal: 106 196 220; + --color-cyan: 90 200 245; + --color-blue: 10 132 255; + --color-indigo: 94 92 230; + --color-purple: 191 90 242; + --color-pink: 255 55 95; + --color-brown: 172 142 104; + --color-gray: 152 152 157; + --color-fill: 255 255 255 / 0.1; + --color-fillSecondary: 255 255 255 / 0.08; + --color-fillTertiary: 255 255 255 / 0.05; + --color-fillQuaternary: 255 255 255 / 0.03; + --color-fillQuinary: 255 255 255 / 0.02; + --color-fillVibrant: 36 36 36; + --color-fillVibrantSecondary: 20 20 20; + --color-fillVibrantTertiary: 13 13 13; + --color-fillVibrantQuaternary: 9 9 9; + --color-fillVibrantQuinary: 7 7 7; + --color-text: 255 255 255 / 0.85; + --color-textSecondary: 255 255 255 / 0.5; + --color-textTertiary: 255 255 255 / 0.25; + --color-textQuaternary: 255 255 255 / 0.1; + --color-textQuinary: 255 255 255 / 0.05; + --color-textVibrant: 229 229 229; + --color-textVibrantSecondary: 124 124 124; + --color-textVibrantTertiary: 65 65 65; + --color-textVibrantQuaternary: 35 35 35; + --color-textVibrantQuinary: 17 17 17; + --color-materialUltraThick: 40 40 40 / 0.84; + --color-materialThick: 40 40 40 / 0.72; + --color-materialMedium: 40 40 40 / 0.6; + --color-materialThin: 40 40 40 / 0.48; + --color-materialUltraThin: 40 40 40 / 0.36; + --color-materialOpaque: 40 40 40; + --color-controlEnabled: 255 255 255 / 0.2; + --color-controlDisabled: 255 255 255 / 0.1; + --color-menu: 246 246 246 / 0.72; + --color-popover: 246 246 246 / 0.6; + --color-titlebar: 60 60 60 / 0.8; + --color-sidebar: 0 0 0 / 0.45; + --color-selectionFocused: 10 130 255 / 0.75; + --color-selectionFocusedFill: 10 130 255; + --color-selectionUnfocused: 255 255 255 / 0.1; + --color-selectionUnfocusedFill: 40 40 40 / 0.65; + --color-headerView: 30 30 30 / 0.8; + --color-tooltip: 0 0 0 / 0.35; + --color-underWindowBackground: 0 0 0 / 0.45; + } +} +``` \ No newline at end of file diff --git a/.cursor/rules/web-component.mdc b/.cursor/rules/web-component.mdc new file mode 100644 index 0000000000..fd4ab50c7d --- /dev/null +++ b/.cursor/rules/web-component.mdc @@ -0,0 +1,14 @@ +--- +description: +globs: +alwaysApply: false +--- +You need to find the available UI components in the project. + +For web applications (apps/dekstop) + +Components are located at `packages/internal/components` and `apps/desktop/layer/renderer/src/components/ui` + +For rn applications (apps/mobile) + +Components are located at `apps/mobile/src/components` \ No newline at end of file diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index c6fbf66f69..1b012501fc 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -22,10 +22,6 @@ updates: - dependency-name: react-native versions: [">=0.78.0"] - # Stuck by unified 11 - - dependency-name: vfile - versions: [">=6.0.0"] - # It's using export map and metro doesn't support it well - dependency-name: unist-util-visit-parents versions: [">=6.0.0"] @@ -57,7 +53,6 @@ updates: - jsonpointer - workbox-precaching - "@pengx17/electron-forge-maker-appimage" - - devlop - "@microflash/remark-callout-directives" - react-native-track-player - react-native-sheet-transitions diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 17fc594941..2232dffd40 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -16,6 +16,10 @@ on: - 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 }} @@ -82,3 +86,22 @@ jobs: 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: + # We don't want to create a new release for mobile + type: "desktop" + + - name: Create Release Draft + if: github.event.inputs.release == 'true' + uses: softprops/action-gh-release@v2 + with: + name: v${{ steps.version.outputs.APP_VERSION }} + draft: false + prerelease: true + tag_name: v${{ steps.version.outputs.APP_VERSION }} + # .aab cannot be installed directly on your Android Emulator or device. + files: ${{ github.workspace }}/build.apk diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 58f7c5aa13..d5acd719d4 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -39,7 +39,7 @@ jobs: run: | git tag ${{ env.tag_version }} git push origin ${{ env.tag_version }} - - name: Trigger Build Workflow + - name: Trigger Build Desktop Workflow if: ${{ env.tag_version }} run: | curl -X POST \ @@ -47,3 +47,12 @@ jobs: -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-desktop.yml/dispatches \ -d '{"ref":"main", "inputs": {"tag_version": "true"}}' + + - name: Trigger Build Android Workflow + if: ${{ env.tag_version }} + run: | + curl -X POST \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-android.yml/dispatches \ + -d '{"ref":"main", "inputs": {"release": "true"}}' diff --git a/README.md b/README.md index 72c6d52939..8c642666c1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@
+ @@ -46,9 +47,9 @@ Feel free to try it using the following methods: | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Any | Browser | | iOS | App Store | +| Android | Google Play App Store | | macOS | Microsoft Store App Store | | Windows | Microsoft Store App Store | -| Android | Google Play | | Linux | App Store | You can also install using the following methods maintained by our community: diff --git a/apps/desktop/changelog/0.5.0.md b/apps/desktop/changelog/0.5.0.md new file mode 100644 index 0000000000..0b32f5ef81 --- /dev/null +++ b/apps/desktop/changelog/0.5.0.md @@ -0,0 +1,29 @@ +# 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 diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 435a4c852f..eb0db6436f 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,5 +1,11 @@ # What's new in vNEXT_VERSION +## Shiny new things + +## Improvements + ## No longer broken -Performance: Fix context menu re-render loop. +## Thanks + +Special thanks to external contributors @ for their valuable contributions diff --git a/apps/desktop/layer/main/export.ts b/apps/desktop/layer/main/export.ts index 5c8d4f0a0b..2987624a8d 100644 --- a/apps/desktop/layer/main/export.ts +++ b/apps/desktop/layer/main/export.ts @@ -1,3 +1,6 @@ +// Export types for renderer to use +export type { IpcServices } from "./src/ipc" export type { RendererHandlers } from "./src/renderer-handlers" -export type { Router } from "./src/tipc" -export { router } from "./src/tipc" + +// Export services for potential main process use +export { services } from "./src/ipc" diff --git a/apps/desktop/layer/main/package.json b/apps/desktop/layer/main/package.json index ec9731f60c..2f08faea41 100644 --- a/apps/desktop/layer/main/package.json +++ b/apps/desktop/layer/main/package.json @@ -11,7 +11,6 @@ }, "exports": { ".": { - "types": "./export.d.ts", "import": "./export.js" } }, @@ -21,7 +20,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@egoist/tipc": "0.3.2", "@electron-toolkit/preload": "3.0.2", "@electron-toolkit/utils": "4.0.0", "@eneris/push-receiver": "4.3.0", @@ -29,25 +27,25 @@ "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", "@openpanel/web": "1.0.1", - "@sentry/electron": "6.5.0", + "@sentry/electron": "6.6.0", "builder-util-runtime": "9.3.1", "cookie-es": "2.0.0", "electron-context-menu": "4.0.5", - "electron-log": "5.3.4", + "electron-log": "5.4.0", "electron-squirrel-startup": "1.0.1", "electron-store": "10.0.1", "electron-updater": "6.6.2", - "es-toolkit": "1.36.0", + "es-toolkit": "1.38.0", "fast-folder-size": "2.4.0", "font-list": "1.5.1", - "i18next": "25.0.1", + "i18next": "25.2.1", "js-yaml": "4.1.0", "linkedom": "0.18.10", "lowdb": "7.0.1", "msedge-tts": "2.0.0", "node-machine-id": "1.1.12", "ofetch": "1.4.1", - "semver": "7.7.1", + "semver": "7.7.2", "tar": "7.4.3", "vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.22" }, @@ -55,10 +53,10 @@ "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@types/js-yaml": "4.0.9", - "@types/node": "22.15.3", + "@types/node": "22.15.23", "electron": "35.1.5", "electron-devtools-installer": "4.0.0", - "hono": "4.7.7", + "hono": "4.7.10", "typescript": "catalog:" } } diff --git a/apps/desktop/layer/main/src/init.ts b/apps/desktop/layer/main/src/init.ts index 817a371241..cb3e74205f 100644 --- a/apps/desktop/layer/main/src/init.ts +++ b/apps/desktop/layer/main/src/init.ts @@ -1,7 +1,7 @@ import path from "node:path" -import { getRendererHandlers, registerIpcMain } from "@egoist/tipc/main" 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 type { MessagingData } from "@follow/shared/hono" @@ -9,15 +9,14 @@ import { app, nativeTheme, Notification, protocol, shell } from "electron" import contextMenu from "electron-context-menu" import { getIconPath } from "./helper" +import { initializeIpcServices } from "./ipc" import { checkAndCleanCodeCache, clearCacheCronJob } from "./lib/cleaner" import { t } from "./lib/i18n" import { store } from "./lib/store" import { updateNotificationsToken } from "./lib/user" import { logger } from "./logger" import { registerAppMenu } from "./menu" -import type { RendererHandlers } from "./renderer-handlers" import { initializeSentry } from "./sentry" -import { router } from "./tipc" import { getMainWindowOrCreate } from "./window" if (process.argv.length === 3 && process.argv[2]!.startsWith("follow-dev:")) { @@ -29,6 +28,7 @@ if (process.argv.length === 3 && process.argv[2]!.startsWith("follow-dev:")) { */ export function initializeAppStage0() { initializeSentry() + initializeIpcServices() } export const initializeAppStage1 = () => { const protocols = [LEGACY_APP_PROTOCOL, APP_PROTOCOL] @@ -43,8 +43,6 @@ export const initializeAppStage1 = () => { } } - registerIpcMain(router) - if (app.dock) { app.dock.setIcon(getIconPath()) } @@ -189,8 +187,8 @@ const registerPushNotifications = async () => { const mainWindow = getMainWindowOrCreate() mainWindow.restore() mainWindow.focus() - const handlers = getRendererHandlers(mainWindow.webContents) - handlers.navigateEntry.send({ + const handlers = callWindowExpose(mainWindow) + handlers.navigateEntry({ feedId: data.feedId, entryId: data.entryId, view: Number.parseInt(data.view), diff --git a/apps/desktop/layer/main/src/ipc/base.ts b/apps/desktop/layer/main/src/ipc/base.ts new file mode 100644 index 0000000000..9d14893fb5 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/base.ts @@ -0,0 +1,118 @@ +import type { IpcMainInvokeEvent, WebContents } from "electron" +import { ipcMain } from "electron" + +// Base context for IPC methods +export interface IpcContext { + sender: WebContents + event: IpcMainInvokeEvent +} + +// Metadata storage for decorated methods +const methodMetadata = new WeakMap>() + +// Decorator for IPC methods +export function IpcMethod(methodName?: string) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const { constructor } = target + + if (!methodMetadata.has(constructor)) { + methodMetadata.set(constructor, new Map()) + } + + const methods = methodMetadata.get(constructor)! + const finalMethodName = methodName || propertyKey + methods.set(propertyKey, finalMethodName) + + return descriptor + } +} + +// Handler registry for IPC methods +export class IpcHandler { + private static instance: IpcHandler + private registeredChannels = new Set() + + static getInstance(): IpcHandler { + if (!IpcHandler.instance) { + IpcHandler.instance = new IpcHandler() + } + return IpcHandler.instance + } + + registerMethod( + channel: string, + handler: (context: IpcContext, ...args: any[]) => Promise | TOutput, + ) { + if (this.registeredChannels.has(channel)) { + return // Already registered + } + + this.registeredChannels.add(channel) + + ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: any[]) => { + const context: IpcContext = { + sender: event.sender, + event, + } + + try { + return await handler(context, ...args) + } catch (error) { + console.error(`Error in IPC method ${channel}:`, error) + throw error + } + }) + } + + // Send events to renderer + sendToRenderer(webContents: WebContents, channel: string, data: T) { + webContents.send(channel, data) + } +} + +// Base class for IPC service groups +export abstract class IpcService { + protected handler = IpcHandler.getInstance() + + constructor(protected groupName: string) { + this.registerMethods() + } + + protected registerMethods(): void { + const { constructor } = this + const methods = methodMetadata.get(constructor) + + if (methods) { + for (const [propertyKey, methodName] of methods) { + const method = (this as any)[propertyKey] + if (typeof method === "function") { + this.registerMethod(methodName, method.bind(this)) + } + } + } + } + + protected registerMethod( + methodName: string, + handler: (context: IpcContext, ...args: any[]) => Promise | TOutput, + ) { + const channel = `${this.groupName}.${methodName}` + this.handler.registerMethod(channel, handler) + } +} + +// Extract method signatures from service class, removing context parameter +export type ExtractServiceMethods = { + [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K] extends ( + context: IpcContext, + ...args: infer Args + ) => infer Output + ? Args extends [] + ? () => AlwaysPromise + : Args extends [infer Input] + ? (input: Input) => AlwaysPromise + : (...args: Args) => AlwaysPromise + : never +} + +type AlwaysPromise = Promise> diff --git a/apps/desktop/layer/main/src/ipc/index.ts b/apps/desktop/layer/main/src/ipc/index.ts new file mode 100644 index 0000000000..0d13b8ab2b --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/index.ts @@ -0,0 +1,39 @@ +import type { ExtractServiceMethods } from "./base" +import { AppService } from "./services/app" +import { AuthService } from "./services/auth" +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 +export const services = { + app: new AppService(), + auth: new AuthService(), + debug: new DebugService(), + dock: new DockService(), + menu: new MenuService(), + reader: new ReaderService(), + setting: new SettingService(), + integration: new IntegrationService(), +} as const + +// Extract method types automatically from services +export type IpcServices = { + app: ExtractServiceMethods + auth: ExtractServiceMethods + debug: ExtractServiceMethods + dock: ExtractServiceMethods + menu: ExtractServiceMethods + reader: ExtractServiceMethods + setting: ExtractServiceMethods + integration: ExtractServiceMethods +} + +// 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") +} diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts new file mode 100644 index 0000000000..dc803c3357 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -0,0 +1,182 @@ +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { callWindowExpose } from "@follow/shared/bridge" +import { DEV } from "@follow/shared/constants" +import { app, BrowserWindow, clipboard, dialog } from "electron" + +import { registerMenuAndContextMenu } from "~/init" +import { i18n } from "~/lib/i18n" +import { registerAppTray } from "~/lib/tray" +import { logger } from "~/logger" +import { cleanupOldRender, loadDynamicRenderEntry } from "~/updater/hot-updater" + +import { downloadFile } from "../../lib/download" +import { checkForAppUpdates, quitAndInstall } from "../../updater" +import { getMainWindow } from "../../window" +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +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 { + constructor() { + super("app") + } + + @IpcMethod() + getAppVersion(): string { + return app.getVersion() + } + + @IpcMethod() + checkForUpdates(): void { + checkForAppUpdates() + } + + @IpcMethod() + switchAppLocale(context: IpcContext, input: string): void { + i18n.changeLanguage(input) + 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 = 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() + 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 { + const { sender: webContents } = context + + const { promise, resolve } = Promise.withResolvers() + + let requestId = -1 + webContents.once("found-in-page", (_, result) => { + resolve(result.requestId === requestId ? result : null) + }) + requestId = webContents.findInPage(input.text, input.options) + return promise + } + + @IpcMethod() + clearSearch(context: IpcContext): void { + context.sender.stopFindInPage("keepSelection") + } + + @IpcMethod() + async download(context: IpcContext, input: string): Promise { + 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) + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts new file mode 100644 index 0000000000..5e4ec93802 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -0,0 +1,19 @@ +import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +export class AuthService extends IpcService { + constructor() { + super("auth") + } + + @IpcMethod() + async sessionChanged(_context: IpcContext): Promise { + await updateNotificationsToken() + } + + @IpcMethod() + async signOut(_context: IpcContext): Promise { + await deleteNotificationsToken() + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/debug.ts b/apps/desktop/layer/main/src/ipc/services/debug.ts new file mode 100644 index 0000000000..42ef0436b7 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/debug.ts @@ -0,0 +1,18 @@ +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +interface InspectElementInput { + x: number + y: number +} + +export class DebugService extends IpcService { + constructor() { + super("debug") + } + + @IpcMethod() + inspectElement(context: IpcContext, input: InspectElementInput): void { + context.sender.inspectElement(input.x, input.y) + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/dock.ts b/apps/desktop/layer/main/src/ipc/services/dock.ts new file mode 100644 index 0000000000..c24f0453ce --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/dock.ts @@ -0,0 +1,41 @@ +import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../../constants/app" +import { apiClient } from "../../lib/api-client" +import { setDockCount } from "../../lib/dock" +import { sleep } from "../../lib/utils" +import { IpcMethod, IpcService } from "../base" + +const pollingMap = { + unread: false, +} + +export class DockService extends IpcService { + constructor() { + super("dock") + } + + @IpcMethod() + async pollingUpdateUnreadCount(): Promise { + if (pollingMap.unread) { + return + } + + pollingMap.unread = true + while (pollingMap.unread) { + await sleep(UNREAD_BACKGROUND_POLLING_INTERVAL) + if (pollingMap.unread) { + await this.updateUnreadCount() + } + } + } + + @IpcMethod() + async cancelPollingUpdateUnreadCount(): Promise { + pollingMap.unread = false + } + + @IpcMethod() + async updateUnreadCount(): Promise { + const res = await apiClient.reads["total-count"].$get() + setDockCount(res.data.count) + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts new file mode 100644 index 0000000000..651cc74a79 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -0,0 +1,83 @@ +import { existsSync } from "node:fs" +import fsp from "node:fs/promises" +import path from "node:path" + +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +// Input types +interface SaveToEagleInput { + url: string + mediaUrls: string[] +} +export class IntegrationService extends IpcService { + constructor() { + super("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 = `${(title || publishedAt).trim().slice(0, 20).replaceAll("/", "-")}.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 { + 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 + } + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/menu.ts b/apps/desktop/layer/main/src/ipc/services/menu.ts new file mode 100644 index 0000000000..aadcfd1b50 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/menu.ts @@ -0,0 +1,81 @@ +import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron" +import { dialog, Menu, ShareMenu } from "electron" + +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +type SerializableMenuItem = Omit & { + submenu?: SerializableMenuItem[] +} + +interface ShowContextMenuInput { + items: SerializableMenuItem[] +} + +interface ShowConfirmDialogInput { + title: string + message: string + options?: Partial +} + +export class MenuService extends IpcService { + constructor() { + super("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 { + const defer = Promise.withResolvers() + 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 { + 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 { + const menu = new ShareMenu({ + urls: [input], + }) + + menu.popup({ + callback: () => { + context.sender.send("menu-closed") + }, + }) + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/reader.ts b/apps/desktop/layer/main/src/ipc/services/reader.ts new file mode 100644 index 0000000000..299e6ea598 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/reader.ts @@ -0,0 +1,126 @@ +import fs from "node:fs" +import path from "node:path" + +import { callWindowExpose } from "@follow/shared/bridge" +import { readability } from "@follow-app/readability" +import { app, BrowserWindow } from "electron" +import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts" +import type { ModelResult } from "vscode-languagedetection" + +import { detectCodeStringLanguage } from "../../modules/language-detection" +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +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 { + constructor() { + super("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 { + 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 { + 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 + } +} diff --git a/apps/desktop/layer/main/src/ipc/services/setting.ts b/apps/desktop/layer/main/src/ipc/services/setting.ts new file mode 100644 index 0000000000..e286325809 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/setting.ts @@ -0,0 +1,90 @@ +import { createRequire } from "node:module" + +import { app, nativeTheme } from "electron" + +import { setDockCount } from "../../lib/dock" +import { setProxyConfig, updateProxy } from "../../lib/proxy" +import { store } from "../../lib/store" +import { getTrayConfig, setTrayConfig } from "../../lib/tray" +import { showSetting } from "../../window" +import type { IpcContext } from "../base" +import { IpcMethod, IpcService } from "../base" + +const require = createRequire(import.meta.url) + +interface SetLoginItemSettingsInput { + openAtLogin: boolean + openAsHidden?: boolean + path?: string + args?: string[] +} + +export class SettingService extends IpcService { + constructor() { + super("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 { + showSetting() + } + + @IpcMethod() + async getSystemFonts(_context: IpcContext): Promise { + 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 + } + + @IpcMethod() + getMinimizeToTray(_context: IpcContext): boolean { + return getTrayConfig() + } + + @IpcMethod() + setMinimizeToTray(_context: IpcContext, minimize: boolean): void { + setTrayConfig(minimize) + } + + @IpcMethod() + setDockBadge(_context: IpcContext, count: number): void { + setDockCount(count) + } + + @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 + } +} diff --git a/apps/desktop/layer/main/src/tipc/_instance.ts b/apps/desktop/layer/main/src/tipc/_instance.ts deleted file mode 100644 index 8ffae628fb..0000000000 --- a/apps/desktop/layer/main/src/tipc/_instance.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { tipc } from "@egoist/tipc/main" - -export const t = tipc.create() - -export type T = typeof t diff --git a/apps/desktop/layer/main/src/tipc/app.ts b/apps/desktop/layer/main/src/tipc/app.ts deleted file mode 100644 index 32567c8e41..0000000000 --- a/apps/desktop/layer/main/src/tipc/app.ts +++ /dev/null @@ -1,331 +0,0 @@ -import fs from "node:fs" -import fsp from "node:fs/promises" -import path from "node:path" -import { fileURLToPath } from "node:url" - -import { getRendererHandlers } from "@egoist/tipc/main" -import { callWindowExpose } from "@follow/shared/bridge" -import { DEV } from "@follow/shared/constants" -import { app, BrowserWindow, clipboard, dialog, shell } from "electron" - -import { filePathToAppUrl } from "~/helper" -import { registerMenuAndContextMenu } from "~/init" -import { clearAllData, getCacheSize } from "~/lib/cleaner" -import { store, StoreKey } from "~/lib/store" -import { registerAppTray } from "~/lib/tray" -import { logger, revealLogFile } from "~/logger" -import { cleanupOldRender, loadDynamicRenderEntry } from "~/updater/hot-updater" - -import { downloadFile } from "../lib/download" -import { i18n } from "../lib/i18n" -import type { RendererHandlers } from "../renderer-handlers" -import { quitAndInstall } from "../updater" -import { getMainWindow } from "../window" -import { t } from "./_instance" - -export const appRoute = { - saveToEagle: t.procedure - .input<{ url: string; mediaUrls: string[] }>() - .action(async ({ input }) => { - 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 - } - }), - - invalidateQuery: t.procedure - .input<(string | number | undefined)[]>() - .action(async ({ input }) => { - const mainWindow = getMainWindow() - if (!mainWindow) return - const handlers = getRendererHandlers(mainWindow.webContents) - handlers.invalidateQuery.send(input) - }), - - windowAction: t.procedure - .input<{ action: "close" | "minimize" | "maximum" }>() - .action(async ({ input, context }) => { - 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": { - // FIXME: this is a electron bug, see https://github.com/RSSNext/Follow/issues/231 - // So in this way we use a workaround to fix it, that is manually resize the window - // Comemnt the manually handle logic and disable backgroundMaterial for now - // if (isWindows11) { - // const display = screen.getDisplayMatching(window.getBounds()) - // const size = display.workAreaSize - - // const isMaximized = size.height === window.getSize()[1] - - // const storeKey = Symbol.for("maximized") - // if (isMaximized) { - // const stored = window[storeKey] - // if (!stored) return - - // window.setResizable(true) - // window.setMovable(true) - - // window.setBounds( - // { - // width: stored.size[0], - // height: stored.size[1], - // x: stored.position[0], - // y: stored.position[1], - // }, - // true, - // ) - - // delete window[storeKey] - // } else { - // const currentWindowSize = window.getSize() - // const currentWindowPosition = window.getPosition() - // window[storeKey] = { - // size: currentWindowSize, - // position: currentWindowPosition, - // } - - // // Maually Resize - // const { workArea } = display - - // window.setBounds( - // { - // x: workArea.x, - // y: workArea.y, - // width: workArea.width, - // height: workArea.height, - // }, - // true, - // ) - - // window.setResizable(false) - // window.setMovable(false) - // } - - // return - // } - - if (window.isMaximized()) { - window.unmaximize() - } else { - window.maximize() - } - - break - } - } - } - }), - - quitAndInstall: t.procedure.action(async () => { - quitAndInstall() - }), - - /// clipboard - - readClipboard: t.procedure.action(async () => clipboard.readText()), - /// search - search: t.procedure - .input<{ - text: string - options: Electron.FindInPageOptions - }>() - .action(async ({ input, context }) => { - const { sender: webContents } = context - - const { promise, resolve } = Promise.withResolvers() - - let requestId = -1 - webContents.once("found-in-page", (_, result) => { - resolve(result.requestId === requestId ? result : null) - }) - requestId = webContents.findInPage(input.text, input.options) - return promise - }), - clearSearch: t.procedure.action(async ({ context: { sender: webContents } }) => { - webContents.stopFindInPage("keepSelection") - }), - - download: t.procedure.input().action(async ({ input, context: { sender } }) => { - const result = await dialog.showSaveDialog({ - defaultPath: input.split("/").pop(), - }) - if (result.canceled) return - - // return result.filePath; - await downloadFile(input, result.filePath).catch((err) => { - const senderWindow = (sender as Sender).getOwnerBrowserWindow() - if (!senderWindow) return - callWindowExpose(senderWindow).toast.error("Download failed!", { - duration: 1000, - }) - throw err - }) - - const senderWindow = (sender as Sender).getOwnerBrowserWindow() - if (!senderWindow) return - callWindowExpose(senderWindow).toast.success("Download success!", { - duration: 1000, - }) - }), - - getAppPath: t.procedure.action(async () => app.getAppPath()), - resolveAppAsarPath: t.procedure.input().action(async ({ input }) => { - return filePathToAppUrl(path.resolve(app.getAppPath(), input)) - }), - - switchAppLocale: t.procedure.input().action(async ({ input }) => { - i18n.changeLanguage(input) - registerMenuAndContextMenu() - registerAppTray() - - app.commandLine.appendSwitch("lang", input) - }), - - clearAllData: t.procedure.action(clearAllData), - - saveToObsidian: t.procedure - .input<{ - url: string - title: string - content: string - author: string - publishedAt: string - vaultPath: string - }>() - .action(async ({ input }) => { - try { - const { url, title, content, author, publishedAt, vaultPath } = input - - const fileName = `${(title || publishedAt).trim().slice(0, 20).replaceAll("/", "-")}.md` - const filePath = path.join(vaultPath, fileName) - const exists = fs.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 } - } - }), - - getAppVersion: t.procedure.action(async () => { - return app.getVersion() - }), - rendererUpdateReload: t.procedure.action(async () => { - 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 = 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) - }), - - getCacheSize: t.procedure.action(async () => { - return getCacheSize() - }), - openCacheFolder: t.procedure.action(async () => { - const dir = path.join(app.getPath("userData"), "cache") - shell.openPath(dir) - }), - getCacheLimit: t.procedure.action(async () => { - return store.get(StoreKey.CacheSizeLimit) - }), - - clearCache: t.procedure.action(async () => { - 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") - }) - }), - - limitCacheSize: t.procedure.input().action(async ({ input }) => { - logger.info("set limitCacheSize", input) - if (input === 0) { - store.delete(StoreKey.CacheSizeLimit) - } else { - store.set(StoreKey.CacheSizeLimit, input) - } - }), - - revealLogFile: t.procedure.action(async () => { - return revealLogFile() - }), -} - -interface Sender extends Electron.WebContents { - getOwnerBrowserWindow: () => Electron.BrowserWindow | null -} diff --git a/apps/desktop/layer/main/src/tipc/auth.ts b/apps/desktop/layer/main/src/tipc/auth.ts deleted file mode 100644 index d06a38e9dc..0000000000 --- a/apps/desktop/layer/main/src/tipc/auth.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { deleteNotificationsToken, updateNotificationsToken } from "~/lib/user" - -import { t } from "./_instance" - -export const authRoute = { - sessionChanged: t.procedure.action(async () => { - await updateNotificationsToken() - }), - - signOut: t.procedure.action(async () => { - await deleteNotificationsToken() - }), -} diff --git a/apps/desktop/layer/main/src/tipc/debug.ts b/apps/desktop/layer/main/src/tipc/debug.ts deleted file mode 100644 index d7544d62aa..0000000000 --- a/apps/desktop/layer/main/src/tipc/debug.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { t } from "./_instance" - -export const debugRoute = { - inspectElement: t.procedure - .input<{ x: number; y: number }>() - .action(async ({ input, context }) => { - context.sender.inspectElement(input.x, input.y) - }), -} diff --git a/apps/desktop/layer/main/src/tipc/dock.ts b/apps/desktop/layer/main/src/tipc/dock.ts deleted file mode 100644 index 8b6444d5a6..0000000000 --- a/apps/desktop/layer/main/src/tipc/dock.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../constants/app" -import { apiClient } from "../lib/api-client" -import { setDockCount } from "../lib/dock" -import { sleep } from "../lib/utils" -import { t } from "./_instance" - -const pollingMap = { - unread: false, -} -export const dockRoute = { - pollingUpdateUnreadCount: t.procedure.action(pollingUpdateUnreadCount), - - cancelPollingUpdateUnreadCount: t.procedure.action(cancelPollingUpdateUnreadCount), - - updateUnreadCount: t.procedure.action(async () => { - await updateUnreadCount() - }), -} - -async function updateUnreadCount() { - const res = await apiClient.reads["total-count"].$get() - setDockCount(res.data.count) -} - -export async function pollingUpdateUnreadCount() { - if (pollingMap.unread) { - return - } - - pollingMap.unread = true - while (pollingMap.unread) { - await sleep(UNREAD_BACKGROUND_POLLING_INTERVAL) - if (pollingMap.unread) { - await updateUnreadCount() - } - } -} - -export async function cancelPollingUpdateUnreadCount() { - pollingMap.unread = false -} diff --git a/apps/desktop/layer/main/src/tipc/index.ts b/apps/desktop/layer/main/src/tipc/index.ts deleted file mode 100644 index 66a472fefc..0000000000 --- a/apps/desktop/layer/main/src/tipc/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { appRoute } from "./app" -import { authRoute } from "./auth" -import { debugRoute } from "./debug" -import { dockRoute } from "./dock" -import { menuRoute } from "./menu" -import { readerRoute } from "./reader" -import { settingRoute } from "./setting" - -export const router = { - ...debugRoute, - ...menuRoute, - ...settingRoute, - ...appRoute, - ...dockRoute, - ...readerRoute, - ...authRoute, -} - -export type Router = typeof router diff --git a/apps/desktop/layer/main/src/tipc/menu.ts b/apps/desktop/layer/main/src/tipc/menu.ts deleted file mode 100644 index c5af20eb58..0000000000 --- a/apps/desktop/layer/main/src/tipc/menu.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ActionContext } from "@egoist/tipc/main" -import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron" -import { dialog, Menu, ShareMenu } from "electron" - -import { t } from "./_instance" - -type SerializableMenuItem = Omit & { - // id: string - submenu?: SerializableMenuItem[] -} - -function normalizeMenuItems( - items: SerializableMenuItem[], - context: ActionContext, - path = [] as 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 ? normalizeMenuItems(item.submenu, context, curPath) : undefined, - } - }) -} - -export const menuRoute = { - showContextMenu: t.procedure - .input<{ - items: SerializableMenuItem[] - }>() - .action(({ input, context }) => { - const defer = Promise.withResolvers() - const normalizedMenuItems = normalizeMenuItems(input.items, context) - - // See https://www.electronjs.org/docs/latest/api/menu - const menu = Menu.buildFromTemplate(normalizedMenuItems) - menu.popup({ - callback: () => defer.resolve(), - }) - return defer.promise - }), - - /** @deprecated */ - showConfirmDialog: t.procedure - .input<{ - title: string - message: string - options?: Partial - }>() - .action(async ({ input }) => { - const result = await dialog.showMessageBox({ - message: input.title, - detail: input.message, - buttons: ["Confirm", "Cancel"], - ...input.options, - }) - return result.response === 0 - }), - - showShareMenu: t.procedure.input().action(async ({ input, context }) => { - const menu = new ShareMenu({ - urls: [input], - }) - - menu.popup({ - callback: () => { - context.sender.send("menu-closed") - }, - }) - }), -} diff --git a/apps/desktop/layer/main/src/tipc/reader.ts b/apps/desktop/layer/main/src/tipc/reader.ts deleted file mode 100644 index 2afc8f96b5..0000000000 --- a/apps/desktop/layer/main/src/tipc/reader.ts +++ /dev/null @@ -1,108 +0,0 @@ -import fs from "node:fs" -import path from "node:path" - -import { callWindowExpose } from "@follow/shared/bridge" -import { readability } from "@follow-app/readability" -import { app, BrowserWindow } from "electron" -import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts" -import type { ModelResult } from "vscode-languagedetection" - -import { detectCodeStringLanguage } from "~/modules/language-detection" - -import { t } from "./_instance" - -const tts = new MsEdgeTTS() - -export const readerRoute = { - readability: t.procedure.input<{ url: string }>().action(async ({ input }) => { - const { url } = input - - if (!url) { - return null - } - const result = await readability(url) - - return result - }), - - tts: t.procedure - .input<{ - id: string - text: string - voice: string - }>() - .action(async ({ input, context: { sender } }) => { - const { id, text, voice } = input - if (!text) { - return null - } - - const window = BrowserWindow.fromWebContents(sender) - if (!window) return - - // It's ok to set voice every time, because it will be cached by msedge-tts - // Don't know why webm is not working, using mp3 instead - 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) { - return callWindowExpose(window).toast.error(error.message, { - duration: 1000, - }) - } - return callWindowExpose(window).toast.error("Failed to set voice", { - duration: 1000, - }) - }) - - 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 - } - }), - - getVoices: t.procedure.action(async ({ context: { sender } }) => { - const window = BrowserWindow.fromWebContents(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 }) - } - }), - - detectCodeStringLanguage: t.procedure - .input<{ codeString: string }>() - .action(async ({ input }) => { - 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 - }), -} diff --git a/apps/desktop/layer/main/src/tipc/setting.ts b/apps/desktop/layer/main/src/tipc/setting.ts deleted file mode 100644 index c7754d13c0..0000000000 --- a/apps/desktop/layer/main/src/tipc/setting.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createRequire } from "node:module" - -import { app, nativeTheme } from "electron" - -import { START_IN_TRAY_ARGS } from "~/constants/app" -import { getTrayConfig, setTrayConfig } from "~/lib/tray" - -import { setDockCount } from "../lib/dock" -import { setProxyConfig, updateProxy } from "../lib/proxy" -import { store } from "../lib/store" -import { showSetting } from "../window" -import { t } from "./_instance" - -const require = createRequire(import.meta.url) -export const settingRoute = { - getLoginItemSettings: t.procedure.input().action(async () => app.getLoginItemSettings()), - setLoginItemSettings: t.procedure.input().action(async ({ input }) => { - app.setLoginItemSettings({ - openAtLogin: input, - openAsHidden: true, - args: [START_IN_TRAY_ARGS], - }) - }), - openSettingWindow: t.procedure.action(async () => showSetting()), - getSystemFonts: t.procedure.action( - async (): Promise => - new Promise((resolve) => { - // NOTE: should external font-list deps - // use `require` to avoid bundling, vite behavior - require("font-list") - .getFonts() - .then((fonts) => { - resolve(fonts.map((font) => font.replaceAll('"', ""))) - }) - }), - ), - getAppearance: t.procedure.action(async () => nativeTheme.themeSource), - setAppearance: t.procedure.input<"light" | "dark" | "system">().action(async ({ input }) => { - nativeTheme.themeSource = input - - store.set("appearance", input) - }), - getMinimizeToTray: t.procedure.action(async () => getTrayConfig()), - setMinimizeToTray: t.procedure.input().action(async ({ input }) => setTrayConfig(input)), - setDockBadge: t.procedure.input().action(async ({ input }) => { - setDockCount(input) - }), - getProxyConfig: t.procedure.action(async () => store.get("proxy")), - setProxyConfig: t.procedure.input().action(async ({ input }) => { - const result = setProxyConfig(input) - updateProxy() - return result - }), - getMessagingToken: t.procedure.action(async () => { - return store.get("notifications-credentials") - }), -} diff --git a/apps/desktop/layer/main/src/updater/configs.ts b/apps/desktop/layer/main/src/updater/configs.ts index 5254747f2e..5de1b88bb7 100644 --- a/apps/desktop/layer/main/src/updater/configs.ts +++ b/apps/desktop/layer/main/src/updater/configs.ts @@ -1,12 +1,12 @@ import path from "node:path" -import { DEV } from "@follow/shared/constants" +import { DEV, MODE, ModeEnum } from "@follow/shared/constants" import { isWindows } from "../env" export const appUpdaterConfig = { // Disable renderer hot update will trigger app update when available - enableRenderHotUpdate: !DEV, + enableRenderHotUpdate: !DEV && MODE !== ModeEnum.staging, enableCoreUpdate: !process.mas && !process.windowsStore && diff --git a/apps/desktop/layer/main/src/updater/index.ts b/apps/desktop/layer/main/src/updater/index.ts index 8ef8b5092b..5047e5216e 100644 --- a/apps/desktop/layer/main/src/updater/index.ts +++ b/apps/desktop/layer/main/src/updater/index.ts @@ -1,4 +1,4 @@ -import { getRendererHandlers } from "@egoist/tipc/main" +import { callWindowExpose } from "@follow/shared/bridge" import { DEV } from "@follow/shared/constants" import { autoUpdater as defaultAutoUpdater } from "electron-updater" @@ -7,7 +7,6 @@ import { canUpdateRender, CanUpdateRenderState, hotUpdateRender } from "~/update import { channel, isWindows } from "../env" import { logger } from "../logger" -import type { RendererHandlers } from "../renderer-handlers" import { destroyMainWindow, getMainWindow } from "../window" import { appUpdaterConfig } from "./configs" import { CustomGitHubProvider } from "./custom-github-provider" @@ -145,9 +144,9 @@ export const registerUpdater = async () => { const mainWindow = getMainWindow() if (!mainWindow) return - const handlers = getRendererHandlers(mainWindow.webContents) + const handlers = callWindowExpose(mainWindow) - handlers.updateDownloaded.send() + handlers.updateDownloaded() }) autoUpdater.on("error", (e) => { logger.error("Error while updating client", e) diff --git a/apps/desktop/layer/main/src/window.ts b/apps/desktop/layer/main/src/window.ts index 0cb76a7b22..e8a988df76 100644 --- a/apps/desktop/layer/main/src/window.ts +++ b/apps/desktop/layer/main/src/window.ts @@ -12,12 +12,12 @@ import type { Event } from "electron/main" import { START_IN_TRAY_ARGS } from "./constants/app" import { isMacOS, isWindows, isWindows11 } from "./env" import { filePathToAppUrl, getIconPath } from "./helper" +import { services } from "./ipc" 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 { cancelPollingUpdateUnreadCount, pollingUpdateUnreadCount } from "./tipc/dock" import { loadDynamicRenderEntry } from "./updater/hot-updater" const windows = { @@ -313,7 +313,7 @@ export const createMainWindow = () => { }) window.on("show", () => { - cancelPollingUpdateUnreadCount() + services.dock.pollingUpdateUnreadCount() const caller = callWindowExpose(window) @@ -325,7 +325,7 @@ export const createMainWindow = () => { const settings = await caller.getUISettings() if (settings?.showDockBadge) { - pollingUpdateUnreadCount() + services.dock.pollingUpdateUnreadCount() } }) diff --git a/apps/desktop/layer/main/tsconfig.json b/apps/desktop/layer/main/tsconfig.json index 0682a8cdeb..a6f78f0af2 100644 --- a/apps/desktop/layer/main/tsconfig.json +++ b/apps/desktop/layer/main/tsconfig.json @@ -10,6 +10,7 @@ "noUnusedParameters": false, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, + "experimentalDecorators": true, "baseUrl": ".", "paths": { "@pkg": ["../../package.json"], diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index cd0ac55419..7f12bc3476 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -14,32 +14,32 @@ "dependencies": { "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", - "@egoist/tipc": "0.3.2", "@electron-toolkit/preload": "3.0.2", "@follow/electron-main": "workspace:*", "@follow/shared": "workspace:*", + "@follow/store": "workspace:*", "@follow/tracker": "workspace:*", "@fontsource/sn-pro": "5.2.5", - "@headlessui/react": "2.2.2", + "@headlessui/react": "2.2.4", "@hookform/resolvers": "4.1.3", - "@lottiefiles/dotlottie-react": "0.13.4", + "@lottiefiles/dotlottie-react": "0.13.5", "@openpanel/web": "1.0.1", - "@radix-ui/react-avatar": "1.1.7", - "@radix-ui/react-context-menu": "2.2.12", - "@radix-ui/react-dialog": "1.1.11", - "@radix-ui/react-dropdown-menu": "2.1.12", - "@radix-ui/react-hover-card": "1.1.11", - "@radix-ui/react-label": "2.1.4", - "@radix-ui/react-popover": "1.1.11", - "@radix-ui/react-slider": "1.3.2", - "@radix-ui/react-slot": "1.2.0", - "@sentry/react": "9.14.0", - "@shikijs/transformers": "3.3.0", - "@tanstack/query-sync-storage-persister": "5.74.7", - "@tanstack/react-query": "5.74.7", - "@tanstack/react-query-devtools": "5.74.7", - "@tanstack/react-query-persist-client": "5.74.7", - "@tanstack/react-virtual": "3.13.6", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-context-menu": "2.2.15", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-dropdown-menu": "2.1.15", + "@radix-ui/react-hover-card": "1.1.14", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-popover": "1.1.14", + "@radix-ui/react-slider": "1.3.5", + "@radix-ui/react-slot": "1.2.3", + "@sentry/react": "9.22.0", + "@shikijs/transformers": "3.4.2", + "@tanstack/query-sync-storage-persister": "5.77.2", + "@tanstack/react-query": "5.77.2", + "@tanstack/react-query-devtools": "5.77.2", + "@tanstack/react-query-persist-client": "5.77.2", + "@tanstack/react-virtual": "3.13.9", "@use-gesture/react": "10.3.1", "@welldone-software/why-did-you-render": "10.0.1", "@yornaath/batshit": "0.10.1", @@ -49,54 +49,54 @@ "dayjs": "1.11.13", "dexie": "4.0.11", "dexie-export-import": "4.1.4", - "dnum": "2.14.0", + "dnum": "2.15.0", "embla-carousel-react": "8.6.0", "embla-carousel-wheel-gestures": "8.0.2", - "es-toolkit": "1.36.0", - "firebase": "11.6.1", + "es-toolkit": "1.38.0", + "firebase": "11.8.1", "foxact": "0.2.45", "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.0.1", - "i18next-browser-languagedetector": "8.0.5", - "idb-keyval": "6.2.1", + "i18next": "25.2.1", + "i18next-browser-languagedetector": "8.1.0", + "idb-keyval": "6.2.2", "immer": "10.1.1", - "jotai": "2.12.3", + "jotai": "2.12.5", "lethargy": "1.0.9", "masonic": "4.1.0", "mdast-util-gfm-table": "2.0.0", "mdast-util-to-markdown": "2.1.2", - "motion": "12.9.2", + "motion": "12.15.0", "nanoid": "5.1.5", "ofetch": "1.4.1", "plain-shiki": "0.2.0", "re-resizable": "6.11.2", "react-blurhash": "0.3.0", "react-fast-marquee": "1.6.5", - "react-hook-form": "7.56.1", - "react-hotkeys-hook": "5.0.1", - "react-i18next": "15.5.1", + "react-hook-form": "7.56.4", + "react-hotkeys-hook": "5.1.0", + "react-i18next": "15.5.2", "react-intersection-observer": "9.16.0", "react-ios-pwa-prompt": "2.0.6", "react-qr-code": "2.0.15", "react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1", - "react-router": "7.5.2", + "react-router": "7.6.1", "react-selecto": "1.26.3", "react-shadow": "20.6.0", "react-zoom-pan-pinch": "3.7.0", - "shiki": "3.3.0", + "shiki": "3.4.2", "sonner": "2.0.3", "tinykeys": "3.0.0", "title-case": "4.3.2", - "tldts": "7.0.4", + "tldts": "7.0.7", "ufo": "1.6.1", "use-context-selector": "2.0.0", "use-sync-external-store": "1.5.0", "usehooks-ts": "3.1.1", - "zod": "3.24.3", - "zustand": "5.0.3" + "zod": "3.25.32", + "zustand": "5.0.5" }, "devDependencies": { "@follow/atoms": "workspace:*", @@ -107,11 +107,11 @@ "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", - "@types/node": "22.15.3", + "@types/node": "22.15.23", "@vite-pwa/assets-generator": "1.0.0", - "fake-indexeddb": "6.0.0", - "happy-dom": "17.4.4", - "react-scan": "0.3.3", + "fake-indexeddb": "6.0.1", + "happy-dom": "17.5.6", + "react-scan": "0.3.4", "typescript": "catalog:" } } diff --git a/apps/desktop/layer/renderer/src/App.tsx b/apps/desktop/layer/renderer/src/App.tsx index 4ba1443a03..e8792ebfd1 100644 --- a/apps/desktop/layer/renderer/src/App.tsx +++ b/apps/desktop/layer/renderer/src/App.tsx @@ -5,33 +5,15 @@ import { cn, getOS } from "@follow/utils/utils" import { useEffect } from "react" import { Outlet } from "react-router" -import { queryClient } from "~/lib/query-client" - import { useAppIsReady } from "./atoms/app" import { useUISettingKey } from "./atoms/settings/ui" -import { navigateEntry } from "./hooks/biz/useNavigateEntry" import { applyAfterReadyCallbacks } from "./initialize/queue" import { removeAppSkeleton } from "./lib/app" import { appLog } from "./lib/log" import { Titlebar } from "./modules/app/Titlebar" import { RootProviders } from "./providers/root-providers" -import { handlers } from "./tipc" function App() { - useEffect(() => { - const cleanup = handlers?.invalidateQuery.listen((queryKey) => { - queryClient.invalidateQueries({ - queryKey, - }) - }) - - handlers?.navigateEntry.listen((options) => { - navigateEntry(options) - }) - - return cleanup - }, []) - const windowsElectron = IN_ELECTRON && getOS() === "Windows" return ( diff --git a/apps/desktop/layer/renderer/src/atoms/ai-summary.ts b/apps/desktop/layer/renderer/src/atoms/ai-summary.ts index 277a0cca98..a66f7b5687 100644 --- a/apps/desktop/layer/renderer/src/atoms/ai-summary.ts +++ b/apps/desktop/layer/renderer/src/atoms/ai-summary.ts @@ -1,7 +1,6 @@ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" -import type { FlatEntryModel } from "~/store/entry/types" import { useGeneralSettingKey } from "./settings/general" @@ -12,12 +11,12 @@ export const toggleShowAISummaryOnce = () => setShowAISummaryOnce((prev) => !pre export const enableShowAISummaryOnce = () => setShowAISummaryOnce(true) export const disableShowAISummaryOnce = () => setShowAISummaryOnce(false) -export const useShowAISummaryAuto = (entry: FlatEntryModel | null) => { - return useGeneralSettingKey("summary") || !!entry?.settings?.summary +export const useShowAISummaryAuto = (settings?: boolean | null) => { + return useGeneralSettingKey("summary") || !!settings } -export const useShowAISummary = (entry: FlatEntryModel | null) => { - const showAISummaryAuto = useShowAISummaryAuto(entry) +export const useShowAISummary = (settings?: boolean | null) => { + const showAISummaryAuto = useShowAISummaryAuto(settings) const showAISummaryOnce = useShowAISummaryOnce() - return showAISummaryAuto || showAISummaryOnce || !!entry?.settings?.summary + return showAISummaryAuto || showAISummaryOnce || !!settings } diff --git a/apps/desktop/layer/renderer/src/atoms/ai-translation.ts b/apps/desktop/layer/renderer/src/atoms/ai-translation.ts index 09a8dd3c70..f5f2569318 100644 --- a/apps/desktop/layer/renderer/src/atoms/ai-translation.ts +++ b/apps/desktop/layer/renderer/src/atoms/ai-translation.ts @@ -1,7 +1,6 @@ import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" -import type { FlatEntryModel } from "~/store/entry/types" import { useGeneralSettingKey } from "./settings/general" @@ -21,12 +20,12 @@ export const toggleShowAITranslationOnce = () => setShowAITranslationOnce((prev) export const enableShowAITranslationOnce = () => setShowAITranslationOnce(true) export const disableShowAITranslationOnce = () => setShowAITranslationOnce(false) -export const useShowAITranslationAuto = (entry: FlatEntryModel | null) => { - return useGeneralSettingKey("translation") || !!entry?.settings?.translation +export const useShowAITranslationAuto = (settings?: boolean | null) => { + return useGeneralSettingKey("translation") || !!settings } -export const useShowAITranslation = (entry: FlatEntryModel | null) => { - const showAITranslationAuto = useShowAITranslationAuto(entry) +export const useShowAITranslation = (settings?: boolean | null) => { + const showAITranslationAuto = useShowAITranslationAuto(settings) const showAITranslationOnce = useShowAITranslationOnce() return showAITranslationAuto || showAITranslationOnce } diff --git a/apps/desktop/layer/renderer/src/atoms/context-menu.ts b/apps/desktop/layer/renderer/src/atoms/context-menu.ts index d4bded497b..40a1ba3704 100644 --- a/apps/desktop/layer/renderer/src/atoms/context-menu.ts +++ b/apps/desktop/layer/renderer/src/atoms/context-menu.ts @@ -3,7 +3,7 @@ import { getOS, transformShortcut } from "@follow/utils/utils" import { atom } from "jotai" import { useCallback } from "react" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { createAtomHooks } from "~/lib/jotai" import type { ElectronMenuItem } from "~/lib/native-menu" import { showElectronContextMenu } from "~/lib/native-menu" @@ -118,7 +118,7 @@ function withDebugMenu(menuItems: Array, e: MouseEvent | React.M new MenuItemText({ label: "Inspect Element", click: () => { - tipcClient?.inspectElement({ + ipcServices?.debug.inspectElement({ x: e.pageX, y: e.pageY, }) diff --git a/apps/desktop/layer/renderer/src/atoms/popover.ts b/apps/desktop/layer/renderer/src/atoms/popover.ts new file mode 100644 index 0000000000..01138ee0cd --- /dev/null +++ b/apps/desktop/layer/renderer/src/atoms/popover.ts @@ -0,0 +1,43 @@ +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(), + }) +} diff --git a/apps/desktop/layer/renderer/src/components/common/ExPromise.tsx b/apps/desktop/layer/renderer/src/components/common/ExPromise.tsx index a48419d7ff..f72d755451 100644 --- a/apps/desktop/layer/renderer/src/components/common/ExPromise.tsx +++ b/apps/desktop/layer/renderer/src/components/common/ExPromise.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useState } from "react" +import type { JSX } from "react/jsx-runtime" const NOT_RESOLVED = Symbol("NOT_RESOLVED") export const ExPromise = ({ diff --git a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx index 78adf3ef1d..301f2b28d7 100644 --- a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx +++ b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx @@ -15,13 +15,13 @@ export const FocusablePresets = { isNotFloatingLayerScope: (v: Set) => !FloatingLayerScope.some((s) => v.has(s)), isSubscriptionList: (scope: Set) => { return ( - scope.size === 0 || - scope.has(HotkeyScope.SubscriptionList) || - (scope.has(HotkeyScope.Home) && scope.size === 1) + 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), +} satisfies Record) => boolean> diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx new file mode 100644 index 0000000000..0ac2c2d65c --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx @@ -0,0 +1,288 @@ +import { IN_ELECTRON } from "@follow/shared/constants" +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 { getEntry } from "~/store/entry" + +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.entries.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.entries + 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 navigator.clipboard.writeText(shareContent.url) + toast.success(t("share.link_copied")) + } + } catch { + // If sharing fails, copy link as fallback + try { + await navigator.clipboard.writeText(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 navigator.clipboard.writeText(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?.entries?.title + return title ? ( +

+ {title} +

+ ) : null + })()} +
+ +
+
+

+ {t("share.social_media")} +

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

+ {t("share.actions")} +

+
+
+ {actionOptions.map((option) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx index 3e2ea51fc2..008bd2c5cb 100644 --- a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx @@ -11,7 +11,7 @@ import type { } from "shiki" import { useUISettingKey } from "~/atoms/settings/ui" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { CopyButton } from "../../button/CopyButton" import { getLanguageIcon } from "../constants" @@ -57,7 +57,7 @@ export const ShikiHighLighter: FC = (props) => { } function guessLanguage() { - return tipcClient?.detectCodeStringLanguage({ codeString: code }).then((result) => { + return ipcServices?.reader.detectCodeStringLanguage({ codeString: code }).then((result) => { if (!result) { return } diff --git a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx index 5eeb426193..7166c218f3 100644 --- a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx @@ -1,3 +1,4 @@ +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" @@ -6,15 +7,21 @@ import { cn } from "@follow/utils/utils" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as React from "react" -import { Focusable } from "~/components/common/Focusable" import { HotkeyScope } from "~/constants" 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], @@ -91,18 +98,16 @@ const DropdownMenuContent = ({ }) => { return ( - - - + ) } diff --git a/apps/desktop/layer/renderer/src/components/ui/markdown/HTML.tsx b/apps/desktop/layer/renderer/src/components/ui/markdown/HTML.tsx index 5096382bed..b2099ce566 100644 --- a/apps/desktop/layer/renderer/src/components/ui/markdown/HTML.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/markdown/HTML.tsx @@ -1,6 +1,7 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js" import katexStyle from "katex/dist/katex.min.css?raw" import { createElement, Fragment, memo, useEffect, 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" diff --git a/apps/desktop/layer/renderer/src/components/ui/media/hooks.tsx b/apps/desktop/layer/renderer/src/components/ui/media/hooks.tsx index 3ef858c1e9..17e34aee8e 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/hooks.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/hooks.tsx @@ -11,7 +11,10 @@ import { PreviewMediaContent } from "./preview-media" export const usePreviewMedia = (children?: React.ReactNode) => { const { present } = useModalStack() return useCallback( - (media: PreviewMediaProps[], initialIndex = 0) => { + (media?: PreviewMediaProps[], initialIndex = 0) => { + if (!media || media.length === 0) { + return + } if (isMobile()) { window.open(replaceImgUrlIfNeed(media[initialIndex]!.url)) return diff --git a/apps/desktop/layer/renderer/src/components/ui/media/preview-media.tsx b/apps/desktop/layer/renderer/src/components/ui/media/preview-media.tsx index e1c0cacbb5..d447f5f65c 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/preview-media.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/preview-media.tsx @@ -17,7 +17,7 @@ import { useWindowSize } from "usehooks-ts" import { m } from "~/components/common/Motion" import { COPY_MAP } from "~/constants" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { replaceImgUrlIfNeed } from "~/lib/img-proxy" import { FixedModalCloseButton } from "../modal/components/close" @@ -114,7 +114,7 @@ const Wrapper: Component<{ { - tipcClient?.download(src) + ipcServices?.app.download(src) }} > diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/custom-modal.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/custom-modal.tsx index 52024644ee..bd04abd5bc 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/custom-modal.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/custom-modal.tsx @@ -3,6 +3,7 @@ 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" diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx index d6fff51322..853802c3d7 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx @@ -366,7 +366,7 @@ export const ModalInternal = memo(function Modal({ onPointerDownCapture={handleDrag} onPointerDown={relocateModal} > - {!!icon && {icon}} + {!!icon && {icon}} {title} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts b/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts index b354842584..3f03a90662 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts @@ -1,6 +1,6 @@ import { useRouteParamsSelector } from "./useRouteParams" -export function useEntryIsRead }>(entry?: T) { +export function useEntryIsRead }>(entry?: T | null) { return useRouteParamsSelector( (params) => { if (params.isCollection) { diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts b/apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts new file mode 100644 index 0000000000..e9298e3739 --- /dev/null +++ b/apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts @@ -0,0 +1,33 @@ +import { checkIsEditableElement } from "@follow/utils" +import { useEffect } from "react" +import { tinykeys } from "tinykeys" + +import type { MenuItemInput } from "~/atoms/context-menu" +import { MenuItemText } from "~/atoms/context-menu" + +export const useContextMenuActionShortCutTrigger = (items: MenuItemInput[], when: boolean) => { + useEffect(() => { + if (!when) return + + const actionMap = items.reduce( + (acc, item) => { + if (item instanceof MenuItemText) { + if (!item.shortcut) return acc + acc[item.shortcut] = (event: KeyboardEvent) => { + if (checkIsEditableElement(event.target as HTMLElement)) return + event.preventDefault() + event.stopPropagation() + if (item.disabled) return + if (item.hide) return + item.click() + } + } + return acc + }, + + {} as Record void>, + ) + + return tinykeys(window, actionMap) + }, [items, when]) +} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useDailyTask.ts b/apps/desktop/layer/renderer/src/hooks/biz/useDailyTask.ts deleted file mode 100644 index 25ac4e80fb..0000000000 --- a/apps/desktop/layer/renderer/src/hooks/biz/useDailyTask.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useCallback, useEffect } from "react" -import { useGoogleReCaptcha } from "react-google-recaptcha-v3" - -import { useClaimCheck, useClaimWalletDailyRewardMutation } from "~/queries/wallet" - -export const useDailyTask = () => { - const { mutateAsync: claimDaily } = useClaimWalletDailyRewardMutation() - const check = useClaimCheck() - const { executeRecaptcha } = useGoogleReCaptcha() - - const handleReCaptchaVerify = useCallback(async () => { - if (check.data?.data && executeRecaptcha) { - const token = await executeRecaptcha("useDailyTask") - claimDaily({ tokenV3: token }) - } - }, [claimDaily, check.data?.data, executeRecaptcha]) - - useEffect(() => { - handleReCaptchaVerify() - }, [handleReCaptchaVerify, check.data?.data]) -} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx index ffebf566ec..cf860a009c 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx @@ -18,17 +18,23 @@ import { import { useShowSourceContent } from "~/atoms/source-content" import { useUserRole, whoami } from "~/atoms/user" import { apiClient } from "~/lib/api-fetch" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { COMMAND_ID } from "~/modules/command/commands/id" import { getCommand, useRunCommandFn } from "~/modules/command/hooks/use-command" import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding" import type { FollowCommandId } from "~/modules/command/types" import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" +import type { FlatEntryModel } from "~/store/entry" import { useEntry } from "~/store/entry" import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" -import { useRouteParamsSelector } from "./useRouteParams" +export const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => { + const status = getReadabilityStatus()[id] + const isTurnOn = status !== ReadabilityStatus.INITIAL && !!status + if (isTurnOn) return + toggleEntryReadability({ id, url }) +} export const toggleEntryReadability = async ({ id, url }: { id: string; url: string }) => { const status = getReadabilityStatus()[id] @@ -59,7 +65,7 @@ export const toggleEntryReadability = async ({ id, url }: { id: string; url: str }) } } catch { - const result = await tipcClient?.readability({ url }) + const result = await ipcServices?.reader.readability({ url }) if (result) { setReadabilityContent({ [id]: result, @@ -131,6 +137,34 @@ export class EntryActionMenuItem extends MenuItemText { } export type EntryActionItem = EntryActionMenuItem | MenuItemSeparator +const entrySelector = (state: FlatEntryModel) => { + const content = state.entries.content || "" + const hasContent = !!content + const doesContentContainsHTMLTags = doesTextContainHTML(content) + + const { summary, translation, readability } = state.settings || {} + + const media = state.entries.media || [] + const images = media.filter((a) => a.type === "photo") + const imagesLength = images.length + + return { + feedId: state.feedId, + inboxId: state.inboxId, + url: state.entries.url, + publishedAt: state.entries.publishedAt, + view: state.view, + read: state.read, + summary, + translation, + readability, + isInCollection: !!state.collections, + hasContent, + doesContentContainsHTMLTags, + imagesLength, + } +} + export const useEntryActions = ({ entryId, view, @@ -140,9 +174,9 @@ export const useEntryActions = ({ view?: FeedViewType compact?: boolean }) => { - const entry = useEntry(entryId) - const isEntryInReadability = useEntryIsInReadability(entry?.entries.id) - const imageLength = entry?.entries.media?.filter((a) => a.type === "photo").length || 0 + const entry = useEntry(entryId, entrySelector) + const isEntryInReadability = useEntryIsInReadability(entryId) + const feed = useFeedById(entry?.feedId, (feed) => { return { type: feed.type, @@ -151,16 +185,13 @@ export const useEntryActions = ({ siteUrl: feed.siteUrl, } }) - const listId = useRouteParamsSelector((s) => s.listId) - const inList = !!listId + const inbox = useInboxById(entry?.inboxId) const isInbox = !!inbox - const isContentContainsHTMLTags = doesTextContainHTML(entry?.entries.content) - const isShowSourceContent = useShowSourceContent() - const isShowAISummaryAuto = useShowAISummaryAuto(entry) + const isShowAISummaryAuto = useShowAISummaryAuto(entry?.summary) const isShowAISummaryOnce = useShowAISummaryOnce() - const isShowAITranslationAuto = useShowAITranslationAuto(entry) + const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation) const isShowAITranslationOnce = useShowAITranslationOnce() const runCmdFn = useRunCommandFn() @@ -227,7 +258,7 @@ export const useEntryActions = ({ new EntryActionMenuItem({ id: COMMAND_ID.entry.star, onClick: runCmdFn(COMMAND_ID.entry.star, [{ entryId, view }]), - active: !!entry?.collections, + active: entry.isInCollection, shortcut: shortcuts[COMMAND_ID.entry.star], entryId, }), @@ -240,7 +271,7 @@ export const useEntryActions = ({ new EntryActionMenuItem({ id: COMMAND_ID.entry.copyLink, onClick: runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId }]), - hide: !entry?.entries.url, + hide: !entry.url, shortcut: shortcuts[COMMAND_ID.entry.copyLink], entryId, }), @@ -251,13 +282,13 @@ export const useEntryActions = ({ }), new EntryActionMenuItem({ id: COMMAND_ID.entry.imageGallery, - hide: imageLength <= 5, + hide: entry.imagesLength <= 5, onClick: runCmdFn(COMMAND_ID.entry.imageGallery, [{ entryId }]), entryId, }), new EntryActionMenuItem({ id: COMMAND_ID.entry.openInBrowser, - hide: !entry?.entries.url, + hide: !entry.url, onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]), entryId, }), @@ -266,7 +297,7 @@ export const useEntryActions = ({ onClick: runCmdFn(COMMAND_ID.entry.viewSourceContent, [ { entryId, siteUrl: feed?.siteUrl }, ]), - hide: isMobile() || !entry?.entries.url, + hide: isMobile() || !entry.url, active: isShowSourceContent, entryId, }), @@ -276,7 +307,7 @@ export const useEntryActions = ({ hide: isShowAISummaryAuto || ([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes( - entry?.view, + entry.view, ), active: isShowAISummaryOnce, disabled: userRole === UserRole.Trial, @@ -288,7 +319,7 @@ export const useEntryActions = ({ hide: isShowAITranslationAuto || ([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes( - entry?.view, + entry.view, ), active: isShowAITranslationOnce, disabled: userRole === UserRole.Trial, @@ -297,28 +328,28 @@ export const useEntryActions = ({ new EntryActionMenuItem({ id: COMMAND_ID.entry.share, onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]), - hide: !entry?.entries.url || !("share" in navigator || IN_ELECTRON), + hide: !entry.url, shortcut: shortcuts[COMMAND_ID.entry.share], entryId, }), new EntryActionMenuItem({ id: COMMAND_ID.entry.readAbove, - onClick: runCmdFn(COMMAND_ID.entry.readAbove, [{ publishedAt: entry.entries.publishedAt }]), - hide: !hasEntry || !!entry.collections, + onClick: runCmdFn(COMMAND_ID.entry.readAbove, [{ publishedAt: entry.publishedAt }]), + hide: !!entry.isInCollection, entryId, }), new EntryActionMenuItem({ id: COMMAND_ID.entry.read, onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]), - hide: !hasEntry || !!entry.collections, - active: !!entry?.read, + hide: !!entry.isInCollection, + active: !!entry.read, shortcut: shortcuts[COMMAND_ID.entry.read], entryId, }), new EntryActionMenuItem({ id: COMMAND_ID.entry.readBelow, - onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.entries.publishedAt }]), - hide: !hasEntry || !!entry.collections, + onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.publishedAt }]), + hide: !!entry.isInCollection, entryId, }), MENU_ITEM_SEPARATOR, @@ -331,27 +362,17 @@ export const useEntryActions = ({ new EntryActionMenuItem({ id: COMMAND_ID.entry.tts, - onClick: runCmdFn(COMMAND_ID.entry.tts, [ - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - { entryId, entryContent: entry?.entries.content! }, - ]), - hide: !IN_ELECTRON || compact || !entry?.entries.content, + onClick: runCmdFn(COMMAND_ID.entry.tts, [{ entryId }]), + hide: !IN_ELECTRON || compact || !entry.hasContent, shortcut: shortcuts[COMMAND_ID.entry.tts], entryId, }), new EntryActionMenuItem({ id: COMMAND_ID.entry.readability, - onClick: runCmdFn(COMMAND_ID.entry.readability, [ - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - { entryId, entryUrl: entry?.entries.url! }, - ]), - hide: - !!entry.settings?.readability || - compact || - (view && views[view]!.wideMode) || - !entry?.entries.url, + onClick: runCmdFn(COMMAND_ID.entry.readability, [{ entryId, entryUrl: entry.url! }]), + hide: !!entry.readability || compact || (view && views[view]!.wideMode) || !entry.url, active: isEntryInReadability, - notice: !isContentContainsHTMLTags && !isEntryInReadability, + notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability, entryId, }), new EntryActionMenuItem({ @@ -378,24 +399,23 @@ export const useEntryActions = ({ isInbox, shortcuts, view, - entry?.collections, - entry?.entries.url, - entry?.entries.publishedAt, - entry?.entries.content, + entry?.isInCollection, + entry?.url, + entry?.publishedAt, + entry?.hasContent, entry?.view, entry?.read, - entry?.settings?.readability, - imageLength, + entry?.readability, + entry?.imagesLength, isShowSourceContent, isShowAISummaryAuto, isShowAISummaryOnce, userRole, isShowAITranslationAuto, isShowAITranslationOnce, - inList, compact, isEntryInReadability, - isContentContainsHTMLTags, + entry?.doesContentContainsHTMLTags, ]) return actionConfigs diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx index b8df350d37..86cc6e83dc 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx @@ -1,4 +1,3 @@ -import { Button } from "@follow/components/ui/button/index.js" import type { FeedViewType } from "@follow/constants" import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" @@ -22,10 +21,9 @@ import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding import { FeedForm } from "~/modules/discover/FeedForm" import { InboxForm } from "~/modules/discover/InboxForm" import { ListForm } from "~/modules/discover/ListForm" -import { - CategoryCreationModalContent, - ListCreationModalContent, -} from "~/modules/settings/tabs/lists/modals" +import { useConfirmUnsubscribeSubscriptionModal } from "~/modules/modal/hooks/useConfirmUnsubscribeSubscriptionModal" +import { useCategoryCreationModal } from "~/modules/settings/tabs/lists/hooks" +import { ListCreationModalContent } from "~/modules/settings/tabs/lists/modals" import { useResetFeed } from "~/queries/feed" import { getFeedById, useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" @@ -41,24 +39,6 @@ import { useNavigateEntry } from "./useNavigateEntry" import { getRouteParams } from "./useRouteParams" import { useBatchUpdateSubscription, useDeleteSubscription } from "./useSubscriptionActions" -const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => void }) => { - const { t } = useTranslation() - - return ( -
-
- - {t("sidebar.feed_actions.unfollow_feed_many_warning")} -
-
- -
-
- ) -} - export const useFeedActions = ({ feedId, feedIds, @@ -89,6 +69,7 @@ export const useFeedActions = ({ useMemo(() => feedIds || [feedId], [feedId, feedIds]), ) const { present } = useModalStack() + const presentDeleteSubscription = useConfirmUnsubscribeSubscriptionModal() const deleteSubscription = useDeleteSubscription({}) const claimFeed = useFeedClaimModal() @@ -99,6 +80,7 @@ export const useFeedActions = ({ const { mutateAsync: removeFeedFromListMutation } = useRemoveFeedFromFeedList() const { mutateAsync: resetFeed } = useResetFeed() const { mutate: addFeedsToCategoryMutation } = useBatchUpdateSubscription() + const presentCategoryCreationModal = useCategoryCreationModal() const openBoostModal = useBoostModal() const listByView = useOwnedListByView(view!) @@ -239,20 +221,7 @@ export const useFeedActions = ({ label: t("sidebar.feed_column.context_menu.create_category"), icon: , click() { - present({ - title: t("sidebar.feed_column.context_menu.title"), - content: () => ( - { - addFeedsToCategoryMutation({ - feedIdList: isMultipleSelection ? feedIds : [feedId], - category, - view: view!, - }) - }} - /> - ), - }) + presentCategoryCreationModal(view!, isMultipleSelection ? feedIds : [feedId]) }, }), ], @@ -281,17 +250,7 @@ export const useFeedActions = ({ supportMultipleSelection: true, click: () => { if (isMultipleSelection) { - present({ - title: t("sidebar.feed_actions.unfollow_feed_many_confirm"), - content: ({ dismiss }) => ( - { - deleteSubscription.mutate({ feedIdList: feedIds }) - dismiss() - }} - /> - ), - }) + presentDeleteSubscription(feedIds) return } deleteSubscription.mutate({ subscription }) @@ -367,30 +326,32 @@ export const useFeedActions = ({ item.supportMultipleSelection), ) }, [ + addFeedToListMutation, + addFeedsToCategoryMutation, + categories, + claimFeed, + deleteSubscription, feed, + feedId, + feedIds, inbox, - t, - shortcuts, isEntryList, isInMASReview, isInbox, - listByView, - categories, isMultipleSelection, - feedId, - feedIds, - claimFeed, - resetFeed, + listByView, + navigateEntry, openBoostModal, - addFeedToListMutation, - removeFeedFromListMutation, present, - subscriptions, + presentCategoryCreationModal, + presentDeleteSubscription, + removeFeedFromListMutation, + resetFeed, + shortcuts, subscription, - addFeedsToCategoryMutation, + subscriptions, + t, view, - deleteSubscription, - navigateEntry, ]) return items diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useProxySetting.ts b/apps/desktop/layer/renderer/src/hooks/biz/useProxySetting.ts index 94ec6de1a4..fb8e1263f1 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useProxySetting.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useProxySetting.ts @@ -2,12 +2,12 @@ import { IN_ELECTRON } from "@follow/shared/constants" import { atom, useAtomValue, useSetAtom } from "jotai" import { useCallback } from "react" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" const proxyAtom = atom("") proxyAtom.onMount = (setAtom) => { - tipcClient?.getProxyConfig().then((proxy) => { + ipcServices?.setting.getProxyConfig().then((proxy) => { setAtom(proxy || "") }) } @@ -22,7 +22,7 @@ export const useSetProxy = () => { return } setProxy(proxyString) - tipcClient?.setProxyConfig(proxyString) + ipcServices?.setting.setProxyConfig(proxyString) }, [setProxy], ) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts b/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts index b8e34abc00..843cc22e60 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts @@ -2,13 +2,16 @@ import { IN_ELECTRON } from "@follow/shared/constants" import { atom, useAtomValue, useSetAtom } from "jotai" import { useCallback } from "react" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" const minimizeToTrayAtom = atom(true) minimizeToTrayAtom.onMount = (setAtom) => { - tipcClient?.getMinimizeToTray().then((proxy: boolean) => { - setAtom(proxy) + const result = ipcServices?.setting.getMinimizeToTray() + Promise.resolve(result).then((proxy) => { + if (typeof proxy === "boolean") { + setAtom(proxy) + } }) } @@ -20,7 +23,7 @@ export const useSetMinimizeToTray = () => { (value: boolean) => { if (!IN_ELECTRON) return setMinimizeToTray(value) - tipcClient?.setMinimizeToTray(value) + ipcServices?.setting.setMinimizeToTray(value) }, [setMinimizeToTray], ) diff --git a/apps/desktop/layer/renderer/src/hooks/common/useFeedSafeUrl.ts b/apps/desktop/layer/renderer/src/hooks/common/useFeedSafeUrl.ts index 098ea52b5c..b23caab742 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/useFeedSafeUrl.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/useFeedSafeUrl.ts @@ -6,13 +6,24 @@ import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" export const useFeedSafeUrl = (entryId: string) => { - const entry = useEntry(entryId) - const feed = useFeedById(entry?.feedId) + const entry = useEntry(entryId, (state) => { + return { + feedId: state.feedId, + inboxId: state.inboxId, + url: state.entries.url, + authorUrl: state.entries.authorUrl, + } + }) + + const feed = useFeedById(entry?.feedId, (feed) => ({ + type: feed?.type, + siteUrl: feed?.siteUrl, + })) const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null) return useMemo(() => { - if (inbox) return entry?.entries.authorUrl - const href = entry?.entries.url + if (inbox) return entry?.authorUrl + const href = entry?.url if (!href) return "#" if (href.startsWith("http")) { @@ -21,8 +32,8 @@ export const useFeedSafeUrl = (entryId: string) => { return href } - const feedSiteUrl = feed?.type === "feed" ? feed.siteUrl : null + const feedSiteUrl = feed?.type === "feed" ? feed?.siteUrl : null if (feedSiteUrl) return resolveUrlWithBase(href, feedSiteUrl) return href - }, [entry?.entries.authorUrl, entry?.entries.url, feed?.type, inbox]) + }, [entry?.authorUrl, entry?.url, feed?.type, feed?.siteUrl, inbox]) } diff --git a/apps/desktop/layer/renderer/src/hooks/common/useSyncTheme.ts b/apps/desktop/layer/renderer/src/hooks/common/useSyncTheme.ts index a45f202546..d7f18a0f04 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/useSyncTheme.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/useSyncTheme.ts @@ -8,14 +8,14 @@ import { import { IN_ELECTRON } from "@follow/shared/constants" import { useCallback, useLayoutEffect } from "react" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" const useSyncThemeElectron = () => { const appIsDark = useDarkQuery() const setTheme = internal_useSetTheme() useLayoutEffect(() => { let isMounted = true - tipcClient?.getAppearance().then((appearance) => { + ipcServices?.setting.getAppearance().then((appearance) => { if (!isMounted) return setTheme(appearance) disableTransition(["[role=switch]>*"])() @@ -38,7 +38,7 @@ export const useSetTheme = () => { setTheme(colorMode) if (IN_ELECTRON) { - tipcClient?.setAppearance(colorMode) + ipcServices?.setting.setAppearance(colorMode) } }, [setTheme], diff --git a/apps/desktop/layer/renderer/src/initialize/index.ts b/apps/desktop/layer/renderer/src/initialize/index.ts index c0caac827b..1b0e49fa73 100644 --- a/apps/desktop/layer/renderer/src/initialize/index.ts +++ b/apps/desktop/layer/renderer/src/initialize/index.ts @@ -34,10 +34,6 @@ const cleanup = subscribeShouldUseIndexedDB((value) => { declare global { interface Window { version: string - recaptchaOptions: { - useRecaptchaNet: boolean - enterprise: boolean - } } } @@ -124,11 +120,6 @@ export const initializeApp = async () => { version: APP_VERSION, rn: false, }) - // Options for react-google-recaptcha - window.recaptchaOptions = { - useRecaptchaNet: true, - enterprise: true, - } } import.meta.hot?.dispose(cleanup) diff --git a/apps/desktop/layer/renderer/src/lib/auth.ts b/apps/desktop/layer/renderer/src/lib/auth.ts index be46cbc0f5..485bbcb666 100644 --- a/apps/desktop/layer/renderer/src/lib/auth.ts +++ b/apps/desktop/layer/renderer/src/lib/auth.ts @@ -6,6 +6,8 @@ const auth = new Auth({ webURL: env.VITE_WEB_URL, }) +export const { authClient } = auth + // @keep-sorted export const { changeEmail, diff --git a/apps/desktop/layer/renderer/src/lib/client.ts b/apps/desktop/layer/renderer/src/lib/client.ts index 2440067549..3cdf372791 100644 --- a/apps/desktop/layer/renderer/src/lib/client.ts +++ b/apps/desktop/layer/renderer/src/lib/client.ts @@ -1,8 +1,25 @@ -import { createClient } from "@egoist/tipc/renderer" -import type { Router } from "@follow/electron-main" +import type { IpcServices } from "@follow/electron-main" -export const tipcClient = window.electron - ? createClient({ - ipcInvoke: window.electron.ipcRenderer.invoke, - }) - : null +function createIpcProxy(): IpcServices | null { + if (!window.electron) { + return null + } + + return new Proxy({} as IpcServices, { + get(target, groupName: string) { + return new Proxy( + {}, + { + get(_, methodName: string) { + return (...args: any[]) => { + const channel = `${groupName}.${methodName}` + return window.electron!.ipcRenderer.invoke(channel, args[0]) + } + }, + }, + ) + }, + }) +} + +export const ipcServices = createIpcProxy() diff --git a/apps/desktop/layer/renderer/src/lib/ipc-events.ts b/apps/desktop/layer/renderer/src/lib/ipc-events.ts new file mode 100644 index 0000000000..5d7396b583 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/ipc-events.ts @@ -0,0 +1,59 @@ +import type { RendererHandlers } from "@follow/electron-main" + +type EventCallback = (data: T) => void + +export function createEventHandlers() { + if (!window.electron) { + // Web fallback - return empty handlers + return { + invalidateQuery: { + listen: () => () => {}, + }, + updateDownloaded: { + listen: () => () => {}, + }, + navigateEntry: { + listen: () => () => {}, + }, + } + } + + return { + invalidateQuery: { + listen: (callback: EventCallback[0]>) => { + const channel = "renderer-event:invalidateQuery" + const handler = (_: any, data: Parameters[0]) => { + callback(data) + } + window.electron!.ipcRenderer.on(channel, handler) + return () => { + window.electron!.ipcRenderer.removeListener(channel, handler) + } + }, + }, + updateDownloaded: { + listen: (callback: EventCallback) => { + const channel = "renderer-event:updateDownloaded" + const handler = () => { + callback() + } + window.electron!.ipcRenderer.on(channel, handler) + return () => { + window.electron!.ipcRenderer.removeListener(channel, handler) + } + }, + }, + navigateEntry: { + listen: (callback: EventCallback[0]>) => { + const channel = "renderer-event:navigateEntry" + const handler = (_: any, data: Parameters[0]) => { + callback(data) + } + window.electron!.ipcRenderer.on(channel, handler) + return () => { + window.electron!.ipcRenderer.removeListener(channel, handler) + } + }, + }, + } +} diff --git a/apps/desktop/layer/renderer/src/lib/load-language.ts b/apps/desktop/layer/renderer/src/lib/load-language.ts index 14ee3884ac..7745398ecb 100644 --- a/apps/desktop/layer/renderer/src/lib/load-language.ts +++ b/apps/desktop/layer/renderer/src/lib/load-language.ts @@ -10,7 +10,7 @@ import { defaultResources } from "~/@types/default-resource" import { i18nAtom, langChain, LocaleCache } from "~/i18n" import { jotaiStore } from "~/lib/jotai" -import { tipcClient } from "./client" +import { ipcServices } from "./client" import { appLog } from "./log" const loadingLangLock = new Set() @@ -29,7 +29,7 @@ export const loadLanguageAndApply = async (lang: string) => { }) } - tipcClient?.switchAppLocale(lang) + ipcServices?.app.switchAppLocale(lang) const { t } = jotaiStore.get(i18nAtom) if (loadingLangLock.has(lang)) return @@ -78,7 +78,8 @@ export const loadLanguageAndApply = async (lang: string) => { if (IN_ELECTRON) { importFilePath = - (await tipcClient?.resolveAppAsarPath(`dist/renderer/locales/${lang}.js`)) || "" + (await (ipcServices as any)?.app.resolveAppAsarPath(`dist/renderer/locales/${lang}.js`)) || + "" } else { importFilePath = `/locales/${lang}.js` } diff --git a/apps/desktop/layer/renderer/src/lib/native-menu.ts b/apps/desktop/layer/renderer/src/lib/native-menu.ts index eff2b946bb..6a294802a2 100644 --- a/apps/desktop/layer/renderer/src/lib/native-menu.ts +++ b/apps/desktop/layer/renderer/src/lib/native-menu.ts @@ -1,6 +1,6 @@ import type { MenuItemConstructorOptions } from "electron" -import { tipcClient } from "./client" +import { ipcServices } from "./client" export type ElectronMenuItem = Omit & { click?: () => void @@ -21,34 +21,34 @@ export const showElectronContextMenu = async (items: Array) => }, ) const itemsWithoutClick = removeClick(items) - await tipcClient?.showContextMenu({ items: itemsWithoutClick }) + await ipcServices?.menu.showContextMenu({ items: itemsWithoutClick }) dispose() } -const removeClick = (item: ElectronMenuItem[]): ElectronMenuItem[] => - item.map(({ click, ...rest }) => { - if (rest.submenu) - return { - ...rest, - submenu: removeClick(rest.submenu), - } - return rest - }) - -// Function to retrieve the menu item based on the provided path -const getMenuItemByPath = (items: ElectronMenuItem[], path: number[]): ElectronMenuItem | null => { - let currentItems = items - let currentItem: ElectronMenuItem | null = null +function getMenuItemByPath( + items: Array, + path: number[], +): ElectronMenuItem | null { + let current: ElectronMenuItem | null = null + let currentLevel = items for (const index of path) { - if (!currentItems || index >= currentItems.length) return null - currentItem = currentItems[index]! - - if (currentItem.submenu && Array.isArray(currentItem.submenu)) { - currentItems = currentItem.submenu - } else { - currentItems = [] + if (index >= currentLevel.length) { + return null + } + current = currentLevel[index] || null + if (current?.submenu && path.indexOf(index) < path.length - 1) { + currentLevel = current.submenu } } - return currentItem + + return current +} + +function removeClick(items: Array): Array { + return items.map((item) => ({ + ...item, + click: undefined, + submenu: item.submenu ? removeClick(item.submenu) : undefined, + })) } diff --git a/apps/desktop/layer/renderer/src/lib/parse-html.ts b/apps/desktop/layer/renderer/src/lib/parse-html.ts index 8421709655..9ba35d2e08 100644 --- a/apps/desktop/layer/renderer/src/lib/parse-html.ts +++ b/apps/desktop/layer/renderer/src/lib/parse-html.ts @@ -108,7 +108,6 @@ export const parseHtml = ( markInlineImage(node) return createElement("i", props, props.children) }, - // @ts-expect-error math: Math, hr: ({ node, ...props }) => createElement("hr", { @@ -117,12 +116,11 @@ export const parseHtml = ( }), input: ({ node, ...props }) => { if (props.type === "checkbox") { - // @ts-expect-error return createElement(Checkbox, { ...props, disabled: false, className: tw`pointer-events-none mr-2`, - }) + } as any) } return createElement("input", props) }, diff --git a/apps/desktop/layer/renderer/src/lib/parsers.ts b/apps/desktop/layer/renderer/src/lib/parsers.ts index 73e922dd4d..b4258077b2 100644 --- a/apps/desktop/layer/renderer/src/lib/parsers.ts +++ b/apps/desktop/layer/renderer/src/lib/parsers.ts @@ -1,10 +1,8 @@ -import type { EntryModel } from "@follow/models/types" import { isTwitterUrl, isXUrl } from "@follow/utils/link-parser" -export const parseSocialMedia = (entry: EntryModel) => { - const { authorUrl, url, guid } = entry +export const parseSocialMedia = (parsedUrl?: string | null) => { + if (!parsedUrl) return - const parsedUrl = authorUrl || url || guid const isX = isXUrl(parsedUrl).validate || isTwitterUrl(parsedUrl).validate if (isX) { diff --git a/apps/desktop/layer/renderer/src/lib/translate.ts b/apps/desktop/layer/renderer/src/lib/translate.ts index 2f7f067637..ae9f6a2855 100644 --- a/apps/desktop/layer/renderer/src/lib/translate.ts +++ b/apps/desktop/layer/renderer/src/lib/translate.ts @@ -6,7 +6,7 @@ import { duplicateIfLengthLessThan } from "@follow/utils/utils" import { franc } from "franc-min" import { getReadabilityContent } from "~/atoms/readability" -import type { FlatEntryModel } from "~/store/entry" +import { getEntry } from "~/store/entry" import { apiClient } from "./api-fetch" @@ -34,27 +34,28 @@ export const checkLanguage = ({ } export async function translate({ - entry, + entryId, view, language, extraFields, part, }: { - entry?: FlatEntryModel | null - view?: number + entryId?: string | null + view?: number | null language?: SupportedActionLanguage extraFields?: string[] part?: string }) { - if (!language || !entry) { + if (!language || !entryId) { return null } - let fields = language && view !== undefined ? views[view!]!.translation.split(",") : [] + let fields = language && typeof view === "number" ? views[view!]!.translation.split(",") : [] if (extraFields) { fields = [...fields, ...extraFields] } - const readabilityContent = getReadabilityContent()[entry.entries.id]?.content + const readabilityContent = getReadabilityContent()[entryId]?.content + const entries = getEntry(entryId)?.entries fields = fields.filter((field) => { if (language && field === "readabilityContent") { if (!readabilityContent) return false @@ -65,9 +66,9 @@ export async function translate({ return !isLanguageMatch } - if (language && entry.entries[field]) { + if (language && entries?.[field]) { const isLanguageMatch = checkLanguage({ - content: entry.entries[field], + content: entries[field], language, }) return !isLanguageMatch @@ -82,7 +83,7 @@ export async function translate({ const res = await apiClient.ai.translation.$get({ query: { - id: entry.entries.id, + id: entryId, language, fields: fields?.join(",") || "title", part, @@ -97,7 +98,7 @@ export async function translate({ } = {} fields.forEach((field) => { - const content = field === "readabilityContent" ? readabilityContent : entry.entries[field] + const content = field === "readabilityContent" ? readabilityContent : entries?.[field] if (content !== res.data?.[field]) { data[field] = res.data?.[field] } diff --git a/apps/desktop/layer/renderer/src/lib/url-builder.ts b/apps/desktop/layer/renderer/src/lib/url-builder.ts index 4ab8874277..6dc99f6f77 100644 --- a/apps/desktop/layer/renderer/src/lib/url-builder.ts +++ b/apps/desktop/layer/renderer/src/lib/url-builder.ts @@ -1,5 +1,24 @@ +import { FeedViewType } from "@follow/constants" import { UrlBuilder as UrlBuilderClass } from "@follow/utils/url-builder" import { WEB_URL } from "~/constants/env" -export const UrlBuilder = new UrlBuilderClass(WEB_URL) +class WebUrlBuilder extends UrlBuilderClass { + constructor() { + super(WEB_URL) + } + + shareEntry( + id: string, + options?: { + view?: FeedViewType + subscriptionId?: string + }, + ) { + const { view = FeedViewType.Articles, subscriptionId = "all" } = options || {} + + return super.join(`timeline/view-${view}/${subscriptionId}/${id}`, { share: "1" }) + } +} + +export const UrlBuilder = new WebUrlBuilder() diff --git a/apps/desktop/layer/renderer/src/main.tsx b/apps/desktop/layer/renderer/src/main.tsx index cb46838bde..439da6cbba 100644 --- a/apps/desktop/layer/renderer/src/main.tsx +++ b/apps/desktop/layer/renderer/src/main.tsx @@ -3,17 +3,24 @@ import "@follow/components/tailwind" import "./styles/main.css" import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants" +import { apiClientSimpleContext, authClientSimpleContext } from "@follow/store/context" import { getOS } from "@follow/utils/utils" import * as React from "react" import ReactDOM from "react-dom/client" import { RouterProvider } from "react-router/dom" +import { apiClient } from "~/lib/api-fetch" +import { authClient } from "~/lib/auth" + import { setAppIsReady } from "./atoms/app" import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants" import { initializeApp } from "./initialize" import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts" import { router } from "./router" +apiClientSimpleContext.provide(apiClient) +authClientSimpleContext.provide(authClient) + initializeApp().finally(() => { import("./push-notification").then(({ registerWebPushNotifications }) => { if (navigator.serviceWorker && WEB_BUILD) { diff --git a/apps/desktop/layer/renderer/src/modules/action/action-card.tsx b/apps/desktop/layer/renderer/src/modules/action/action-card.tsx index 59ebd8531b..61a55b665b 100644 --- a/apps/desktop/layer/renderer/src/modules/action/action-card.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/action-card.tsx @@ -2,11 +2,11 @@ import { Card, CardContent, CardHeader } from "@follow/components/ui/card/index. import { Input } from "@follow/components/ui/input/index.js" import { Switch } from "@follow/components/ui/switch/index.jsx" import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js" +import { useActionRule } from "@follow/store/action/hooks" +import { actionActions } from "@follow/store/action/store" import { clsx } from "@follow/utils/utils" import { useTranslation } from "react-i18next" -import { actionActions, useActionByIndex } from "~/store/action" - import { FeedFilter } from "./feed-filter" import { TargetActionList } from "./target-action-list" @@ -23,7 +23,7 @@ export const ActionCard = ({ index }: { index: number }) => { "absolute -right-2 -top-2 z-[1] opacity-100 duration-200 hover:!opacity-100 group-hover:opacity-70 lg:opacity-0", )} onClick={() => { - actionActions.removeByIndex(index) + actionActions.deleteRule(index) }} > @@ -44,10 +44,9 @@ export const ActionCard = ({ index }: { index: number }) => { const ActionCardToolbar = ({ index }: { index: number }) => { const { t } = useTranslation("settings") - const name = useActionByIndex(index, (a) => a.name) - const disabled = useActionByIndex(index, (a) => a.result.disabled) + const name = useActionRule(index, (a) => a.name) + const disabled = useActionRule(index, (a) => a.result.disabled) - const onChange = actionActions.updateByIndex.bind(null, index) return (

{t("actions.action_card.name")}

@@ -55,9 +54,7 @@ const ActionCardToolbar = ({ index }: { index: number }) => { value={name} className="h-8 max-w-64" onChange={(e) => { - onChange((data) => { - data.name = e.target.value - }) + actionActions.patchRule(index, { name: e.target.value }) }} />
@@ -65,8 +62,8 @@ const ActionCardToolbar = ({ index }: { index: number }) => { { - onChange((data) => { - data.result.disabled = !checked + actionActions.patchRule(index, { + result: { disabled: !checked }, }) }} /> diff --git a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx index 7fdd04f6d2..bd876d9d54 100644 --- a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx @@ -1,19 +1,22 @@ import { Button } from "@follow/components/ui/button/index.js" import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx" -import { useMutation } from "@tanstack/react-query" +import { + useActionRules, + useIsActionDataDirty, + usePrefetchActions, + useUpdateActionsMutation, +} from "@follow/store/action/hooks" +import { actionActions } from "@follow/store/action/store" import { useTranslation } from "react-i18next" import { unstable_usePrompt } from "react-router" import { toast } from "sonner" -import { toastFetchError } from "~/lib/error-parser" import { queryClient } from "~/lib/query-client" import { ActionCard } from "~/modules/action/action-card" -import { useActionsQuery } from "~/queries/actions" -import { actionActions, useActions, useIsActionDataDirty } from "~/store/action" export const ActionSetting = () => { - const actionQuery = useActionsQuery() - const actionLength = useActions((actions) => actions.length) + const actionQuery = usePrefetchActions() + const actionLength = useActionRules((actions) => actions.length) if (actionQuery.isPending) { return } size="large" /> @@ -32,7 +35,7 @@ export const ActionSetting = () => { function ActionSettingOperations() { const { t } = useTranslation("settings") - const actionLength = useActions((actions) => actions.length) + const actionLength = useActionRules((actions) => actions.length) const isDirty = useIsActionDataDirty() unstable_usePrompt({ message: t("actions.navigate.prompt"), @@ -40,8 +43,7 @@ function ActionSettingOperations() { isDirty && currentLocation.pathname !== nextLocation.pathname, }) - const mutation = useMutation({ - mutationFn: () => actionActions.updateRemoteActions(), + const mutation = useUpdateActionsMutation({ onSuccess: () => { // apply new action settings queryClient.invalidateQueries({ @@ -50,7 +52,7 @@ function ActionSettingOperations() { toast(t("actions.saveSuccess")) }, onError: (error) => { - toastFetchError(error) + toast.error(error) }, }) @@ -59,7 +61,7 @@ function ActionSettingOperations() {
- ) -} - -const UserRenderer = ({ - user, - avatarClassName, -}: { - user?: NonNullable["data"]>[number][ - | "fromUser" - | "toUser"] - avatarClassName?: string -}) => { - const { t } = useTranslation("settings") - const me = useWhoami() - const isMe = user?.id === me?.id - - const name = isMe ? t("wallet.transactions.you") : user?.name || APP_NAME - - const presentUserModal = usePresentUserProfileModal("drawer") - return ( - { - if (user?.id) presentUserModal(user.id) - }} - className="cursor-button flex w-full min-w-0 items-center gap-2" - > - - -
- - {isMe ? ( - {t("wallet.transactions.you")} - ) : ( - {name} - )} - -
-
- ) -} diff --git a/apps/desktop/layer/renderer/src/modules/power/transaction-section/index.tsx b/apps/desktop/layer/renderer/src/modules/power/transaction-section/index.tsx index 53369561e2..9bc417e8cc 100644 --- a/apps/desktop/layer/renderer/src/modules/power/transaction-section/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/transaction-section/index.tsx @@ -4,7 +4,6 @@ import { TransactionTypes } from "@follow/models/types" import { useState } from "react" import { useTranslation } from "react-i18next" -import { useServerConfigs } from "~/atoms/server-configs" import { useWhoami } from "~/atoms/user" import { getBlockchainExplorerUrl } from "~/lib/utils" import { SettingSectionTitle } from "~/modules/settings/section" @@ -27,18 +26,11 @@ export const TransactionsSection: Component = ({ className }) => { type: type === "all" ? undefined : (type as (typeof TransactionTypes)[number]), }) - const serverConfigs = useServerConfigs() - if (!myWallet) return null return (
-

- {t("wallet.transactions.description", { - percentage: Number.parseInt(serverConfigs?.TAX_POINT || "0") / 100, - })} -

setType(val)}> {tabs.map((tab) => ( diff --git a/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx b/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx index 5ac0eb1522..55c0e309f8 100644 --- a/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx +++ b/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx @@ -208,7 +208,7 @@ export const TwoFactorForm = () => { return totpURI ? (
- +
{ const { entryId } = use(EntryInfoContext) - const entry = useEntry(entryId) - const mediaDuration = formatTimeToSeconds(entry?.entries.attachments?.[0]?.duration_in_seconds) + const entry = useEntry(entryId, (state) => { + const attachments = state.entries.attachments || [] + const firstAttachment = attachments[0] + const { duration_in_seconds, url: firstAttachmentUrl } = firstAttachment || {} + const seconds = duration_in_seconds ? (formatTimeToSeconds(duration_in_seconds) ?? 0) : 0 + + return { + firstAttachmentUrl, + seconds, + } + }) + const src = entry?.firstAttachmentUrl + const mediaDuration = entry?.seconds - const src = entry?.entries?.attachments?.[0]?.url if (!src) return {props.time} const seekTo = timeStringToSeconds(props.time) diff --git a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx index 247c3048c6..4b2501cf10 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx @@ -1,5 +1,6 @@ import { FeedViewType } from "@follow/constants" import { useMemo } from "react" +import type { JSX } from "react/jsx-runtime" import { MarkdownImageRecordContext, @@ -26,11 +27,9 @@ export function EntryContentHTMLRenderer } & HTMLProps) { - const entry = useEntry(entryId) - - const images: Record = useMemo(() => { - return ( - entry?.entries.media?.reduce( + const entry = useEntry(entryId, (state) => { + const images = + state.entries.media?.reduce( (acc, media) => { if (media.height && media.width) { acc[media.url] = media @@ -39,8 +38,13 @@ export function EntryContentHTMLRenderer, ) ?? {} - ) - }, [entry]) + + return { + images, + } + }) + + const images: Record = useMemo(() => entry?.images ?? {}, [entry]) const actions: MarkdownRenderActions = useMemo(() => { return { isAudio() { diff --git a/apps/desktop/layer/renderer/src/modules/settings/control.tsx b/apps/desktop/layer/renderer/src/modules/settings/control.tsx index f73836f99d..0fe7ef9426 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/control.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/control.tsx @@ -121,7 +121,7 @@ export const SettingActionItem = ({ }) => (
{label}
-
diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/EnhancedIndicator.tsx b/apps/desktop/layer/renderer/src/modules/settings/helper/EnhancedIndicator.tsx index 129a200020..c316f6fc69 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/EnhancedIndicator.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/EnhancedIndicator.tsx @@ -8,6 +8,8 @@ import { IconTransition } from "~/components/ux/transition/icon" export const EnhancedSettingsIndicator = () => { const enhancedSettings = useGeneralSettingKey("enhancedSettings") const { t } = useTranslation("settings") + + if (!enhancedSettings) return null return ( diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts b/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts index bbe99c7f0d..6b1c2983b6 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts @@ -1,3 +1,5 @@ +import type { JSX } from "react/jsx-runtime" + import type { SettingItem } from "./setting-builder" import { createSettingBuilder } from "./setting-builder" diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/withSettingEnable.tsx b/apps/desktop/layer/renderer/src/modules/settings/helper/withSettingEnable.tsx index 74a12c1071..31d6f553ce 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/withSettingEnable.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/withSettingEnable.tsx @@ -1,5 +1,6 @@ import type { FC } from "react" import { createElement } from "react" +import type { JSX } from "react/jsx-runtime" type WithSelect = T & { select: (_s: any) => any diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/content.mobile.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/content.mobile.tsx index aa5320e51e..1ea6f16ee5 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/content.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/content.mobile.tsx @@ -1,3 +1,4 @@ +import { useTypeScriptHappyCallback } from "@follow/hooks" import PKG from "@pkg" import { createElement, Suspense } from "react" import { Trans, useTranslation } from "react-i18next" @@ -21,12 +22,15 @@ export const MobileSettingModalContent = () => {
{ - present({ - title: "", - content: () => , - }) - }} + onChange={useTypeScriptHappyCallback( + (tab) => { + present({ + title: "", + content: () => , + }) + }, + [present], + )} />
diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/content.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/content.tsx index 1acb7afe40..25c16d50a3 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/content.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/content.tsx @@ -1,13 +1,16 @@ import { ScrollArea } from "@follow/components/ui/scroll-area/index.js" +import { cn } from "@follow/utils" import { repository } from "@pkg" import type { FC } from "react" import { Suspense, useDeferredValue, useLayoutEffect, useState } from "react" import { Trans } from "react-i18next" +import { useLoaderData } from "react-router" import { ModalClose } from "~/components/ui/modal/stacked/components" import { SettingsTitle } from "~/modules/settings/title" import { getSettingPages } from "../settings-glob" +import type { SettingPageConfig } from "../utils" import { SettingTabProvider, useSettingTab } from "./context" import { SettingModalLayout } from "./layout" @@ -39,6 +42,7 @@ const Content = () => { } }, [key]) + const config = (useLoaderData() || loader || {}) as SettingPageConfig if (!Component) return null return ( @@ -49,12 +53,15 @@ const Content = () => { mask={false} ref={setScroller} rootClassName="h-full grow flex-1 shrink-0 overflow-auto pl-8 pr-7" - viewportClassName="px-1 min-h-full [&>div]:min-h-full [&>div]:relative pb-8" + viewportClassName={cn( + "px-1 min-h-full [&>div]:min-h-full [&>div]:relative", + config.viewportClassName, + )} > -
-

+

+

void item: SettingPageConfig path: string - + isActive: boolean onChange?: (tab: string) => void }) => { - const { tab, setTab, item, path, onChange } = props + const { setTab, item, path, onChange, isActive } = props const { disableIf } = item const ctx = useSettingPageContext() @@ -174,7 +173,7 @@ const SettingItemButtonImpl = (props: {

-
+
+ {IN_ELECTRON && ( + + )} + + +
Analytics
+
+ + {/* Feed List */} + + + {/* Sticky Action Bar at bottom when scrolled */} + + {selectedFeeds.size > 0 && ( + <> + +
+ {selectedFeeds.size} item(s) selected + +
+
+ +
+ {/* + + + + + + + + */} + + + + Move to View + + + + + + + + + Unsubscribe + +
+
+ + )} +
+
+ )} + + ) +} + +const SortedFeedsList: FC<{ + feeds: Array<{ id: string }> + analyticsAtom: PrimitiveAtom + sortField: SortField + sortDirection: SortDirection + selectedFeeds: Set + onSelect: (feedId: string, checked: boolean) => void +}> = ({ feeds, sortField, sortDirection, selectedFeeds, onSelect, analyticsAtom }) => { + const sortedFeedIds = useMemo(() => { + switch (sortField) { + case "date": { + return feeds + .sort((a, b) => { + const aSubscription = getSubscriptionByFeedId(a.id) + const bSubscription = getSubscriptionByFeedId(b.id) + if (!aSubscription || !bSubscription) return 0 + const aDate = new Date(aSubscription.createdAt) + const bDate = new Date(bSubscription.createdAt) + return sortDirection === "asc" + ? aDate.getTime() - bDate.getTime() + : bDate.getTime() - aDate.getTime() + }) + .map((f) => f.id) + } + case "view": { + return feeds + .sort((a, b) => { + const aSubscription = getSubscriptionByFeedId(a.id) + const bSubscription = getSubscriptionByFeedId(b.id) + if (!aSubscription || !bSubscription) return 0 + return sortDirection === "asc" + ? aSubscription.view - bSubscription.view + : bSubscription.view - aSubscription.view + }) + .map((f) => f.id) + } + case "name": { + return feeds + .sort((a, b) => { + const aSubscription = getSubscriptionByFeedId(a.id) + const bSubscription = getSubscriptionByFeedId(b.id) + if (!aSubscription || !bSubscription) return 0 + const aFeed = getFeedById(a.id) + const bFeed = getFeedById(b.id) + if (!aFeed || !bFeed) return 0 + const aCompareTitle = aSubscription.title || aFeed.title || "" + const bCompareTitle = bSubscription.title || bFeed.title || "" + return sortDirection === "asc" + ? sortByAlphabet(aCompareTitle, bCompareTitle) + : sortByAlphabet(bCompareTitle, aCompareTitle) + }) + .map((f) => f.id) + } + } + }, [feeds, sortDirection, sortField]) + + return sortedFeedIds.map((feedId) => ( + + )) +} + +const ViewSelector: FC<{ selectedFeeds: Set }> = ({ selectedFeeds }) => { + const { t: tCommon } = useTranslation("common") + const { mutate: batchUpdateSubscription } = useBatchUpdateSubscription() + const { ask } = useDialog() + return views.map((view) => { + return ( + { + ask({ + title: "Confirm", + message: `Are you sure you want to move these feeds to ${tCommon(view.name)}?`, + onConfirm: () => { + batchUpdateSubscription({ + feedIdList: Array.from(selectedFeeds), + view: view.view, + }) + }, + }) + }} + > + {tCommon(view.name)} + + ) + }) +} + +const FeedListItem = memo( + ({ + id, + selected, + onSelect, + analyticsAtom, + }: { + id: string + selected: boolean + onSelect: (feedId: string, checked: boolean) => void + analyticsAtom: PrimitiveAtom + }) => { + const analytics = useAtomValue( + useMemo(() => selectAtom(analyticsAtom, (a) => a[id]), [analyticsAtom, id]), + ) + const subscription = useSubscriptionByFeedId(id) + const feed = useFeedById(id) + const isCustomizeName = subscription?.title && feed?.title !== subscription?.title + const { t: tCommon } = useTranslation("common") + + if (!subscription) return null + + return ( +
onSelect(id, !selected)} + > +
+ onSelect(id, !!checked)} /> +
+
+ +
+ {feed?.errorAt ? ( + + + + {subscription.title || feed?.title} + + + + + {feed?.errorMessage || "Feed has encountered an error"} + + + + ) : ( + + {subscription.title || feed?.title} + + )} + {isCustomizeName && ( + + {feed?.title} + + )} +
+
+ +
+ {views[subscription.view]!.icon} + {tCommon(views[subscription.view]!.name)} +
+
+ +
+
+ {analytics ? ( +
+
+ + +
+ + + {formatNumber(analytics.subscriptionCount || 0)} + +
+
+ + Subscription Count + +
+ + +
+ + + {Math.round(analytics.updatesPerWeek || 0) || "0"} + {"/w"} + +
+
+ + Updates Per Week + +
+
+ {analytics.latestEntryPublishedAt && ( + + +
+ + +
+
+ Latest Entry Published +
+ )} +
+ ) : ( +
--
+ )} +
+
+ ) + }, +) + +const FeedClaimedSection = () => { const { t } = useTranslation("settings") const claimedList = useAuthQuery(Queries.feed.claimedList()) + const numberFormatter = useMemo(() => new Intl.NumberFormat("en-US", {}), []) + return (
+
+

{t("feeds.claim")}

+

{t("feeds.claimTips")}

- - {claimedList.data?.length ? ( + {claimedList.isLoading ? ( + + ) : !claimedList.data?.length ? ( +
+

{t("feeds.noFeeds")}

+
+ ) : null} + {claimedList.data?.length ? ( + @@ -61,7 +611,7 @@ export const SettingFeeds = () => { - {row.subscriptionCount} + {numberFormatter.format(row.subscriptionCount)} {BigInt(row.tipAmount || 0n)} @@ -70,14 +620,8 @@ export const SettingFeeds = () => { ))}
- ) : claimedList.isLoading ? ( - - ) : ( -
-

{t("feeds.noFeeds")}

-
- )} -
+
+ ) : null}
) diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/general.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/general.tsx index 146692bc83..c7f466b1c8 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/general.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/general.tsx @@ -23,7 +23,7 @@ import { import { useProxyValue, useSetProxy } from "~/hooks/biz/useProxySetting" import { useMinimizeToTrayValue, useSetMinimizeToTray } from "~/hooks/biz/useTraySetting" import { fallbackLanguage } from "~/i18n" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { setTranslationCache } from "~/modules/entry-content/atoms" import { SettingDescription, SettingInput, SettingSwitch } from "../control" @@ -40,15 +40,21 @@ const { defineSettingItem: _defineSettingItem, SettingBuilder } = createSetting( ) const saveLoginSetting = (checked: boolean) => { - tipcClient?.setLoginItemSettings(checked) + ipcServices?.setting.setLoginItemSettings({ + openAtLogin: checked, + openAsHidden: true, + args: ["--startup"], + }) setGeneralSetting("appLaunchOnStartup", checked) } export const SettingGeneral = () => { const { t } = useTranslation("settings") useEffect(() => { - tipcClient?.getLoginItemSettings().then((settings) => { - setGeneralSetting("appLaunchOnStartup", settings.openAtLogin) + ipcServices?.setting.getLoginItemSettings().then((settings) => { + if (settings) { + setGeneralSetting("appLaunchOnStartup", settings.openAtLogin) + } }) }, []) @@ -73,7 +79,7 @@ export const SettingGeneral = () => { defineSettingItem("appLaunchOnStartup", { label: t("general.launch_at_login"), - disabled: !tipcClient, + disabled: !ipcServices, onChange(value) { saveLoginSetting(value) }, @@ -173,7 +179,7 @@ const VoiceSelector = () => { const { t } = useTranslation("settings") const { data } = useQuery({ - queryFn: () => tipcClient?.getVoices(), + queryFn: () => ipcServices?.reader.getVoices(), queryKey: ["voices"], meta: { persist: true, diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/hooks.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/hooks.tsx new file mode 100644 index 0000000000..b4dfcb012f --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/hooks.tsx @@ -0,0 +1,32 @@ +import type { FeedViewType } from "@follow/constants" +import { useCallback } from "react" +import { useTranslation } from "react-i18next" + +import { useModalStack } from "~/components/ui/modal/stacked/hooks" +import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions" + +import { CategoryCreationModalContent } from "./modals" + +export const useCategoryCreationModal = () => { + const { t } = useTranslation() + const { present } = useModalStack() + const { mutate: addFeedsToCategoryMutation } = useBatchUpdateSubscription() + return useCallback( + (view: FeedViewType, feedIds: string[]) => + present({ + title: t("sidebar.feed_column.context_menu.title"), + content: () => ( + { + addFeedsToCategoryMutation({ + feedIdList: feedIds, + category, + view: view!, + }) + }} + /> + ), + }), + [addFeedsToCategoryMutation, present, t], + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx index 26d92f6e02..834b7e2755 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx @@ -18,7 +18,7 @@ import { TooltipTrigger, } from "@follow/components/ui/tooltip/index.jsx" import { views } from "@follow/constants" -import { cn } from "@follow/utils/utils" +import { cn, formatNumber } from "@follow/utils/utils" import { useMutation } from "@tanstack/react-query" import { useMemo } from "react" import { toast } from "sonner" @@ -175,7 +175,7 @@ export const SettingLists = () => {
- {listDataMap[row.id]?.subscriptionCount} + {formatNumber(listDataMap[row.id]?.subscriptionCount || 0)} {BigInt(listDataMap[row.id]?.purchaseAmount || 0n)} diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx index 95c315d86c..407c7db45c 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx @@ -24,7 +24,7 @@ import { toast } from "sonner" import { setAppMessagingToken, useAppMessagingToken } from "~/atoms/app" import { useCurrentModal } from "~/components/ui/modal/stacked/hooks" import { useI18n } from "~/hooks/common" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { useMessaging, useTestMessaging } from "~/queries/messaging" export const SettingNotifications = () => { @@ -37,8 +37,8 @@ export const SettingNotifications = () => { const testMessaging = useTestMessaging() useEffect(() => { - tipcClient?.getMessagingToken().then((credentials) => { - setAppMessagingToken(credentials?.fcm?.token || null) + ipcServices?.setting.getMessagingToken().then((credentials) => { + setAppMessagingToken(credentials || null) }) }, []) diff --git a/apps/desktop/layer/renderer/src/modules/settings/utils.ts b/apps/desktop/layer/renderer/src/modules/settings/utils.ts index 4b79314e10..2aafb53e46 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/utils.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/utils.ts @@ -21,6 +21,7 @@ export interface SettingPageConfig { ctx: SettingPageContext, serverConfigs?: ServerConfigs | null, ) => [boolean, DisableWhy] + viewportClassName?: string } export const defineSettingPageData = (config: SettingPageConfig) => ({ ...config, diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx index fde32a87fe..9e6899605b 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx @@ -354,13 +354,15 @@ function FilterReadFeedCategory(props: FeedCategoryProps) { return } -export function FeedCategoryAutoHideUnread(props: FeedCategoryProps) { +export const FeedCategoryAutoHideUnread = memo(function FeedCategoryAutoHideUnread( + props: FeedCategoryProps, +) { const hideAllReadSubscriptions = useHideAllReadSubscriptions() if (hideAllReadSubscriptions) { return } return -} +}) const RenameCategoryForm: FC<{ currentCategory: string diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx index 40617c1b65..755d868053 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx @@ -1,3 +1,4 @@ +import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" import { useMobile } from "@follow/components/hooks/useMobile.js" import { OouiUserAnonymous } from "@follow/components/icons/OouiUserAnonymous.jsx" import { Button } from "@follow/components/ui/button/index.js" @@ -12,10 +13,13 @@ import type { FeedViewType } from "@follow/constants" import { cn, isKeyForMultiSelectPressed } from "@follow/utils/utils" import { createElement, memo, use, useCallback, useState } from "react" import { useTranslation } from "react-i18next" +import { useEventCallback } from "usehooks-ts" import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu" import { useHideAllReadSubscriptions } from "~/atoms/settings/general" import { ErrorTooltip } from "~/components/common/ErrorTooltip" +import { FocusablePresets } from "~/components/common/Focusable" +import { useContextMenuActionShortCutTrigger } from "~/hooks/biz/useContextMenuActionShortCutTrigger" import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions" import { useFollow } from "~/hooks/biz/useFollow" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" @@ -117,10 +121,15 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) => view, }) + const when = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList) + + const whenTrigger = when && isActive + useContextMenuActionShortCutTrigger(items, whenTrigger) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const showContextMenu = useShowContextMenu() const contextMenuProps = useContextMenu({ - onContextMenu: async (e) => { + onContextMenu: useEventCallback(async (e) => { const nextItems = items.concat() if (!feed) return @@ -148,9 +157,12 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) => setIsContextMenuOpen(true) await showContextMenu(nextItems, e) setIsContextMenuOpen(false) - }, + }), }) const follow = useFollow() + const handleDoubleClick = useEventCallback(() => { + window.open(UrlBuilder.shareFeed(feedId, view), "_blank") + }) if (!feed) return null @@ -173,9 +185,7 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) => className, )} onClick={handleClick} - onDoubleClick={() => { - window.open(UrlBuilder.shareFeed(feedId, view), "_blank") - }} + onDoubleClick={handleDoubleClick} {...contextMenuProps} >
@@ -252,6 +262,9 @@ const ListItemImpl: Component = ({ const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId) const items = useListActions({ listId, view }) + const when = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList) + useContextMenuActionShortCutTrigger(items, when && isActive) + const listUnread = useUnreadByListId(listId) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) @@ -273,13 +286,16 @@ const ListItemImpl: Component = ({ const { t } = useTranslation() const contextMenuProps = useContextMenu({ - onContextMenu: async (e) => { + onContextMenu: useEventCallback(async (e) => { setIsContextMenuOpen(true) await showContextMenu(items, e) setIsContextMenuOpen(false) - }, + }), }) const follow = useFollow() + const handleDoubleClick = useEventCallback(() => { + window.open(UrlBuilder.shareList(listId, view), "_blank") + }) if (!list) return null return ( @@ -289,9 +305,7 @@ const ListItemImpl: Component = ({ data-active={isActive || isContextMenuOpen} className={cn(feedColumnStyles.item, "py-1 pl-2.5", className)} onClick={handleNavigate} - onDoubleClick={() => { - window.open(UrlBuilder.shareList(listId, view), "_blank") - }} + onDoubleClick={handleDoubleClick} {...contextMenuProps} >
@@ -358,6 +372,9 @@ const InboxItemImpl: Component = ({ view, inboxId, className, ic const isActive = useRouteParamsSelector((routerParams) => routerParams.inboxId === inboxId) const { items } = useInboxActions({ inboxId }) + const when = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList) + useContextMenuActionShortCutTrigger(items, when && isActive) + const inboxUnread = useUnreadById(inboxId) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx index e3683b23ba..166ab1114e 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx @@ -1,4 +1,4 @@ -import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js" +import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" import { ActionButton } from "@follow/components/ui/button/index.js" import { RootPortal } from "@follow/components/ui/portal/index.js" import { Routes } from "@follow/constants" @@ -243,11 +243,15 @@ const CommandsHandler = ({ setActive: (args: string | ((prev: string | undefined, index: number) => string)) => void timelineList: string[] }) => { - const activeScope = useGlobalFocusableScope() - const when = - activeScope.has(HotkeyScope.SubscriptionList) || - activeScope.has(HotkeyScope.Timeline) || - activeScope.size === 0 + const when = useGlobalFocusableScopeSelector( + // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback + useCallback( + (activeScope) => + activeScope.or(HotkeyScope.SubscriptionList, HotkeyScope.Timeline) || + activeScope.size === 0, + [], + ), + ) useCommandBinding({ commandId: COMMAND_ID.subscription.switchTabToNext, @@ -263,13 +267,13 @@ const CommandsHandler = ({ return EventBus.subscribe(COMMAND_ID.subscription.switchTabToNext, () => { setActive((_, i) => timelineList[(i + 1) % timelineList.length]!) }) - }, [activeScope, setActive, timelineList]) + }, [setActive, timelineList]) useEffect(() => { return EventBus.subscribe(COMMAND_ID.subscription.switchTabToPrevious, () => { setActive((_, i) => timelineList[(i - 1 + timelineList.length) % timelineList.length]!) }) - }, [activeScope, setActive, timelineList]) + }, [setActive, timelineList]) return null } diff --git a/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx b/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx index 7e0b2b71cf..7334b45e71 100644 --- a/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx +++ b/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx @@ -2,27 +2,17 @@ import { Spring } from "@follow/components/constants/spring.js" import { tracker } from "@follow/tracker" import { cn } from "@follow/utils/utils" import { m, useMotionTemplate, useMotionValue } from "motion/react" -import { useCallback, useEffect, useRef } from "react" +import { useCallback, useRef } from "react" import { useTranslation } from "react-i18next" import { useAudioPlayerAtomSelector } from "~/atoms/player" import { getUpdaterStatus, setUpdaterStatus, useUpdaterStatus } from "~/atoms/updater" -import { tipcClient } from "~/lib/client" -import { handlers } from "~/tipc" +import { ipcServices } from "~/lib/client" export const UpdateNotice = () => { const updaterStatus = useUpdaterStatus() const { t } = useTranslation() - useEffect(() => { - return handlers?.updateDownloaded.listen(() => { - setUpdaterStatus({ - type: "app", - status: "ready", - }) - }) - }, []) - const handleClick = useRef(() => { const status = getUpdaterStatus() if (!status) return @@ -31,11 +21,11 @@ export const UpdateNotice = () => { }) switch (status.type) { case "app": { - tipcClient?.quitAndInstall() + ipcServices?.app.quitAndInstall() break } case "renderer": { - tipcClient?.rendererUpdateReload() + ipcServices?.app.rendererUpdateReload() break } case "pwa": { diff --git a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.electron.tsx b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.electron.tsx index de05bf3ef8..7234b57b30 100644 --- a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.electron.tsx @@ -28,16 +28,12 @@ import { useAchievementModal } from "~/modules/achievement/hooks" import { usePresentUserProfileModal } from "~/modules/profile/hooks" import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack" import { signOut, useSession } from "~/queries/auth" -import { useWallet } from "~/queries/wallet" import { useActivationModal } from "../activation" import { COMMAND_ID } from "../command/commands/id" import { useCommandShortcuts } from "../command/hooks/use-command-binding" -import { ActivityPoints } from "../wallet/activity-points" -import { Level } from "../wallet/level" import type { LoginProps } from "./LoginButton" import { LoginButton } from "./LoginButton" -import { PowerButton } from "./ProfileButton.shared" import { UserAvatar } from "./UserAvatar" const rsshubLogo = new URL(rsshubLogoUrl, import.meta.url).href @@ -59,9 +55,6 @@ export const ProfileButton: FC = memo((props) => { const navigate = useNavigate() const role = useUserRole() - const wallet = useWallet() - const { isLoading: isLoadingWallet } = wallet - const myWallet = wallet.data?.[0] const presentActivationModal = useActivationModal() const zenModeSetting = useIsZenMode() const setZenMode = useSetZenMode() @@ -106,25 +99,6 @@ export const ProfileButton: FC = memo((props) => {
- {!isInMASReview && ( - { - navigate("/power") - }} - > -
- - - -
-
- )} - = memo((props) => { {t("user_button.achievement")} + {!isInMASReview && ( + { + navigate("/power") + }} + icon={} + > + {t("user_button.power")} + + )} + {!zenModeSetting && ( diff --git a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.mobile.tsx b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.mobile.tsx index 892821f8ce..03c60a8e5a 100644 --- a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.mobile.tsx @@ -11,16 +11,12 @@ import rsshubLogoUrl from "~/assets/rsshub-icon.png?url" import { useUserRole, useWhoami } from "~/atoms/user" import { UrlBuilder } from "~/lib/url-builder" import { signOut } from "~/queries/auth" -import { useWallet } from "~/queries/wallet" import { useAchievementModal } from "../achievement/hooks" import { useActivationModal } from "../activation" import { usePresentUserProfileModal } from "../profile/hooks" import { useSettingModal } from "../settings/modal/use-setting-modal-hack" -import { ActivityPoints } from "../wallet/activity-points" -import { Level } from "../wallet/level" import type { ProfileButtonProps } from "./ProfileButton.electron" -import { PowerButton } from "./ProfileButton.shared" import { UserAvatar } from "./UserAvatar" export const ProfileButton: FC = () => { @@ -31,9 +27,6 @@ export const ProfileButton: FC = () => { const { t } = useTranslation() const role = useUserRole() - const wallet = useWallet() - const { isLoading: isLoadingWallet } = wallet - const myWallet = wallet.data?.[0] const presentActivationModal = useActivationModal() const settingModalPresent = useSettingModal() return ( @@ -57,19 +50,6 @@ export const ProfileButton: FC = () => {
- - - - - -
diff --git a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.shared.tsx b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.shared.tsx deleted file mode 100644 index 7a7b6b46f1..0000000000 --- a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.shared.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { WalletModel } from "@follow/models" -import { cn } from "@follow/utils/utils" - -import { Balance } from "../wallet/balance" - -export const PowerButton: Component<{ - isLoading: boolean - myWallet?: WalletModel - className?: string -}> = ({ isLoading, myWallet, className }) => { - return ( -
- - {isLoading ? ( - - ) : ( - {BigInt(myWallet?.powerToken || 0n)} - )} -
- ) -} diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx index 52cf0235c9..c427a7ad3e 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx @@ -4,7 +4,7 @@ import { Input } from "@follow/components/ui/input/Input.js" import { LoadingCircle } from "@follow/components/ui/loading/index.js" import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js" import { CategoryMap, RSSHubCategories } from "@follow/constants" -import { cn } from "@follow/utils/utils" +import { cn, formatNumber } from "@follow/utils/utils" import { keepPreviousData } from "@tanstack/react-query" import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" @@ -49,7 +49,7 @@ export const Component = () => { const data: RouteData = rsshubPopular.data as any - const rsshubAnalytics = useAuthQuery(Queries.discover.rsshubAnalytics(), { + const rsshubAnalytics = useAuthQuery(Queries.discover.rsshubAnalytics({ lang }), { staleTime: 1000 * 60 * 60 * 24, // 1 day placeholderData: keepPreviousData, }) @@ -281,7 +281,7 @@ const RecommendationListItem = ({ {analytics?.subscriptionCount && (
- {analytics?.subscriptionCount} + {formatNumber(analytics?.subscriptionCount || 0)}
)}
diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/power/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/power/index.tsx index c968349429..5f5bcae171 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/power/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/power/index.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next" import { useSubViewTitle } from "~/modules/app-layout/subview/hooks" import { MyWalletSection } from "~/modules/power/my-wallet-section" -import { PowerRanking } from "~/modules/power/ranking" import { TransactionsSection } from "~/modules/power/transaction-section" export function Component() { @@ -25,9 +24,6 @@ export function Component() { - - - diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx index b7669286ae..9d696ff258 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx @@ -98,10 +98,10 @@ function List({ data }: { data?: RSSHubModel[] }) { - Follow + Folo - Follow Built-in RSSHub + Folo Built-in RSSHub 0 diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/index.tsx index 632b26e93b..92446b0a56 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/index.tsx @@ -2,7 +2,7 @@ import { isMobile } from "@follow/components/hooks/useMobile.js" import { redirect } from "react-router" import { ROUTE_ENTRY_PENDING } from "~/constants" -import { MobileCenterColumnScreen } from "~/modules/app-layout/entry-column/index" +import { MobileCenterColumnScreen } from "~/modules/app-layout/timeline-column/index" export const Component = () => { return diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx index 9c5f5d9698..673123dde6 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx @@ -1 +1 @@ -export { CenterColumnLayout as Component } from "~/modules/app-layout/entry-column/index" +export { CenterColumnLayout as Component } from "~/modules/app-layout/timeline-column/index" diff --git a/apps/desktop/layer/renderer/src/pages/(main)/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/index.tsx index 23c860b86b..db602572f8 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/index.tsx @@ -4,7 +4,7 @@ import { redirect } from "react-router" import { getGeneralSettings } from "~/atoms/settings/general" import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants" -import { MobileFeedScreen } from "~/modules/app-layout/feed-column/index" +import { MobileFeedScreen } from "~/modules/app-layout/subscription-column/index" export function Component() { return diff --git a/apps/desktop/layer/renderer/src/pages/(main)/layout.tsx b/apps/desktop/layer/renderer/src/pages/(main)/layout.tsx index a61ab5ea50..a15406a6f6 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/layout.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/layout.tsx @@ -1 +1 @@ -export { LeftSidebarLayout as Component } from "~/modules/app-layout/feed-column/index" +export { LeftSidebarLayout as Component } from "~/modules/app-layout/subscription-column/index" diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx index 229c34a70b..6467cb1c7a 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx @@ -9,7 +9,6 @@ export const loader = defineSettingPageData({ icon: iconName, name: "titles.feeds", priority, - hideIf: (ctx) => ctx.isInMASReview, }) export function Component() { diff --git a/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx b/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx index e2945ac5b2..1381b20c6a 100644 --- a/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx @@ -7,11 +7,13 @@ import { toast } from "sonner" import { setWindowState } from "~/atoms/app" import { getGeneralSettings } from "~/atoms/settings/general" import { getUISettings, useToggleZenMode } from "~/atoms/settings/ui" -import { setUpdaterStatus } from "~/atoms/updater" +import { setUpdaterStatus, useUpdaterStatus } from "~/atoms/updater" import { useDialog, useModalStack } from "~/components/ui/modal/stacked/hooks" import { useDiscoverRSSHubRouteModal } from "~/hooks/biz/useDiscoverRSSHubRoute" import { useFollow } from "~/hooks/biz/useFollow" +import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { oneTimeToken } from "~/lib/auth" +import { queryClient } from "~/lib/query-client" import { usePresentUserProfileModal } from "~/modules/profile/hooks" import { useSettingModal } from "~/modules/settings/modal/use-setting-modal" import { handleSessionChanges } from "~/queries/auth" @@ -26,7 +28,17 @@ declare module "@follow/components/providers/stable-router-provider.js" { export const ExtensionExposeProvider = () => { const { present } = useModalStack() const showSettings = useSettingModal() - + const updaterStatus = useUpdaterStatus() + useLayoutEffect(() => { + registerGlobalContext({ + updateDownloaded() { + setUpdaterStatus({ + type: "app", + status: "ready", + }) + }, + }) + }, [updaterStatus]) useLayoutEffect(() => { registerGlobalContext({ showSetting: (path) => window.router.showSettings(path), @@ -38,7 +50,7 @@ export const ExtensionExposeProvider = () => { return env.VITE_API_URL }, getWebUrl() { - return window.location.origin + return env.VITE_WEB_URL }, clearIfLoginOtherAccount(newUserId: string) { @@ -55,6 +67,12 @@ export const ExtensionExposeProvider = () => { status: "ready", }) }, + invalidateQuery(queryKey: string | string[]) { + queryClient.invalidateQueries({ + queryKey: Array.isArray(queryKey) ? queryKey : [queryKey], + }) + }, + navigateEntry, }) }, []) useEffect(() => { diff --git a/apps/desktop/layer/renderer/src/providers/global-focusable-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-focusable-provider.tsx new file mode 100644 index 0000000000..0cb5909800 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/global-focusable-provider.tsx @@ -0,0 +1,39 @@ +import { + useGlobalFocusableScopeSelector, + useSetGlobalFocusableScope, +} from "@follow/components/common/Focusable/hooks.js" +import { useRefValue } from "@follow/hooks" +import type { EnhanceSet } from "@follow/utils" +import { EventBus } from "@follow/utils/event-bus" +import { useEffect } from "react" + +import { useHasModal } from "~/components/ui/modal/stacked/hooks" +import { HotkeyScope } from "~/constants" +import { getRouteParams } from "~/hooks/biz/useRouteParams" +import { COMMAND_ID } from "~/modules/command/commands/id" + +const selector = (s: EnhanceSet) => s.size === 0 +export const FocusableGuardProvider = () => { + const hasNoFocusable = useGlobalFocusableScopeSelector(selector) + const setGlobalFocusableScope = useSetGlobalFocusableScope() + const hasModal = useHasModal() + const hasModalRef = useRefValue(hasModal) + + useEffect(() => { + const timer: NodeJS.Timeout = setTimeout(() => { + if (hasNoFocusable) { + if (hasModalRef.current) { + setGlobalFocusableScope(HotkeyScope.Modal, "append") + } else { + const { timelineId } = getRouteParams() + + if (timelineId) { + EventBus.dispatch(COMMAND_ID.layout.focusToSubscription, { highlightBoundary: false }) + } + } + } + }, 100) + return () => clearTimeout(timer) + }, [hasModalRef, hasNoFocusable, setGlobalFocusableScope]) + return null +} diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx index 08ed1b94b9..ecb2aeb232 100644 --- a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -1,3 +1,4 @@ +import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" import { highlightElement } from "@follow/components/common/Focusable/utils.js" import { checkIsEditableElement, @@ -9,6 +10,7 @@ import { useEffect } from "react" import { tinykeys } from "tinykeys" import { useEventListener } from "usehooks-ts" +import { FocusablePresets } from "~/components/common/Focusable" import { COMMAND_ID } from "~/modules/command/commands/id" import { useRunCommandFn } from "~/modules/command/hooks/use-command" import { useCommandBinding, useCommandShortcuts } from "~/modules/command/hooks/use-command-binding" @@ -32,6 +34,7 @@ export const GlobalHotkeysProvider = () => { const commandShortcuts = useCommandShortcuts() const runCommandFn = useRunCommandFn() + const isNormalLayer = useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope) useEffect(() => { const preHandler = (e: Event) => { stopPropagation(e) @@ -44,11 +47,12 @@ export const GlobalHotkeysProvider = () => { highlightElement(document.activeElement as HTMLElement) }, [commandShortcuts[COMMAND_ID.layout.toggleZenMode]]: (e) => { + if (!isNormalLayer) return preHandler(e) runCommandFn(COMMAND_ID.layout.toggleZenMode, [])() }, }) - }, [commandShortcuts, runCommandFn]) + }, [commandShortcuts, runCommandFn, isNormalLayer]) return null } diff --git a/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts b/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts index bc1d2e1ddd..4389d45ac0 100644 --- a/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts +++ b/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts @@ -4,3 +4,5 @@ export { ExternalJumpInProvider as LazyExternalJumpInProvider } from "../externa export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container" export const LazyReloadPrompt = () => null export const LazyPWAPrompt = () => null + +export { PopoverProvider as LazyPopoverProvider } from "../popover-provider" diff --git a/apps/desktop/layer/renderer/src/providers/lazy/index.rn.ts b/apps/desktop/layer/renderer/src/providers/lazy/index.rn.ts deleted file mode 100644 index 83f3297b97..0000000000 --- a/apps/desktop/layer/renderer/src/providers/lazy/index.rn.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { ContextMenuProvider as LazyContextMenuProvider } from "../context-menu-provider" -export { ExtensionExposeProvider as LazyExtensionExposeProvider } from "../extension-expose-provider" -export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container" -export { ModalStackProvider as LazyModalStackProvider } from "~/components/ui/modal" - -const noop = () => null -export const LazyReloadPrompt = noop -export const LazyPWAPrompt = noop -export const LazyExternalJumpInProvider = noop diff --git a/apps/desktop/layer/renderer/src/providers/lazy/index.ts b/apps/desktop/layer/renderer/src/providers/lazy/index.ts index 196751387b..b041eb8143 100644 --- a/apps/desktop/layer/renderer/src/providers/lazy/index.ts +++ b/apps/desktop/layer/renderer/src/providers/lazy/index.ts @@ -10,6 +10,11 @@ const LazyContextMenuProvider = lazy(() => default: res.ContextMenuProvider, })), ) +const LazyPopoverProvider = lazy(() => + import("./../popover-provider").then((res) => ({ + default: res.PopoverProvider, + })), +) const LazyExtensionExposeProvider = lazy(() => import("./../extension-expose-provider").then((res) => ({ @@ -47,6 +52,7 @@ export { LazyContextMenuProvider, LazyExtensionExposeProvider, LazyLottieRenderContainer, + LazyPopoverProvider, LazyPWAPrompt, LazyReloadPrompt, } diff --git a/apps/desktop/layer/renderer/src/providers/popover-provider.tsx b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx new file mode 100644 index 0000000000..94f9959c84 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx @@ -0,0 +1,81 @@ +// Import from the correct path +import { useSetGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js" +import { Spring } from "@follow/components/constants/spring.js" +import { + Popover, + PopoverArrow, + PopoverContent, + PopoverTrigger, +} from "@follow/components/ui/popover/index.jsx" +import { AnimatePresence, m } from "motion/react" +import { memo, useEffect, useRef } from "react" + +import { usePopoverState } from "~/atoms/popover" +import { HotkeyScope } from "~/constants" + +export const PopoverProvider: Component = ({ children }) => ( + <> + {children} + + +) + +const Handler = memo(() => { + const ref = useRef(null) + const [popoverState, setPopoverState] = usePopoverState() + const setGlobalFocusableScope = useSetGlobalFocusableScope() + + useEffect(() => { + if (!popoverState.open) return + const triggerElement = ref.current + if (!triggerElement) return + + triggerElement.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ) + }, [popoverState]) + + return ( + { + if (state) { + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") + } else { + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") + setPopoverState({ open: false }) + } + }} + > + + + + {popoverState.open && ( + + + {popoverState.content} + + )} + + + + ) +}) + +Handler.displayName = "PopoverHandler" diff --git a/apps/desktop/layer/renderer/src/providers/root-providers.tsx b/apps/desktop/layer/renderer/src/providers/root-providers.tsx index 1eb9e2d5eb..c868c69f6c 100644 --- a/apps/desktop/layer/renderer/src/providers/root-providers.tsx +++ b/apps/desktop/layer/renderer/src/providers/root-providers.tsx @@ -4,19 +4,18 @@ import { EventProvider } from "@follow/components/providers/event-provider.js" import { StableRouterProvider } from "@follow/components/providers/stable-router-provider.js" import { Toaster } from "@follow/components/ui/toast/index.jsx" import { IN_ELECTRON } from "@follow/shared/constants" -import { env } from "@follow/shared/env.desktop" import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client" import { Provider } from "jotai" import type { FC, PropsWithChildren } from "react" import { Suspense } from "react" -import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3" import { ModalStackProvider } from "~/components/ui/modal" import { jotaiStore } from "~/lib/jotai" import { persistConfig, queryClient } from "~/lib/query-client" import { FollowCommandManager } from "~/modules/command/command-manager" +import { FocusableGuardProvider } from "./global-focusable-provider" import { HotkeyProvider } from "./hotkey-provider" import { I18nProvider } from "./i18n-provider" import { InvalidateQueryProvider } from "./invalidate-query-provider" @@ -25,6 +24,7 @@ import { LazyExtensionExposeProvider, LazyExternalJumpInProvider, LazyLottieRenderContainer, + LazyPopoverProvider, LazyPWAPrompt, LazyReloadPrompt, } from "./lazy/index" @@ -33,50 +33,46 @@ import { SettingSync } from "./setting-sync" import { UserProvider } from "./user-provider" export const RootProviders: FC = ({ children }) => ( - - - - - - - - - - + + + + + + + + + - - + + - - - + + + - {import.meta.env.DEV && } + {import.meta.env.DEV && } - {children} + {children} - - - - - - - {!IN_ELECTRON && } - - - - - + + + + + + + + {!IN_ELECTRON && } + + + + + + - - - - - + + + + ) const Devtools = () => diff --git a/apps/desktop/layer/renderer/src/providers/setting-sync.tsx b/apps/desktop/layer/renderer/src/providers/setting-sync.tsx index 1ac9bab2a7..d7b918389f 100644 --- a/apps/desktop/layer/renderer/src/providers/setting-sync.tsx +++ b/apps/desktop/layer/renderer/src/providers/setting-sync.tsx @@ -8,7 +8,7 @@ import { I18N_LOCALE_KEY } from "~/constants" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import { useSyncTheme } from "~/hooks/common" import { langChain } from "~/i18n" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { loadLanguageAndApply } from "~/lib/load-language" import { unreadActions } from "~/store/unread" @@ -38,9 +38,12 @@ const useUISettingSync = () => { useEffect(() => { if (setting.showDockBadge) { - return unreadActions.subscribeUnreadCount((count) => tipcClient?.setDockBadge(count), true) + return unreadActions.subscribeUnreadCount( + (count) => ipcServices?.setting.setDockBadge(count), + true, + ) } else { - tipcClient?.setDockBadge(0) + ipcServices?.setting.setDockBadge(0) } return }, [setting.showDockBadge]) diff --git a/apps/desktop/layer/renderer/src/queries/_.ts b/apps/desktop/layer/renderer/src/queries/_.ts index 84b188e23e..e44d9f29f4 100644 --- a/apps/desktop/layer/renderer/src/queries/_.ts +++ b/apps/desktop/layer/renderer/src/queries/_.ts @@ -1,4 +1,3 @@ -export { action } from "./actions" export { ai } from "./ai" export { auth } from "./auth" export { discover } from "./discover" diff --git a/apps/desktop/layer/renderer/src/queries/actions.ts b/apps/desktop/layer/renderer/src/queries/actions.ts deleted file mode 100644 index de8604f998..0000000000 --- a/apps/desktop/layer/renderer/src/queries/actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useAuthQuery } from "~/hooks/common/useBizQuery" -import { defineQuery } from "~/lib/defineQuery" -import { actionActions } from "~/store/action" - -export const action = { - getAll: () => defineQuery(["actions"], () => actionActions.fillRemoteActions()), -} - -export const useActionsQuery = () => - useAuthQuery(action.getAll(), { - staleTime: Infinity, - }) diff --git a/apps/desktop/layer/renderer/src/queries/ai.ts b/apps/desktop/layer/renderer/src/queries/ai.ts index 2451a2878c..a0c6b528d4 100644 --- a/apps/desktop/layer/renderer/src/queries/ai.ts +++ b/apps/desktop/layer/renderer/src/queries/ai.ts @@ -1,26 +1,28 @@ import type { SupportedLanguages } from "@follow/models/types" +import { getReadabilityContent } from "~/atoms/readability" import { apiClient } from "~/lib/api-fetch" import { defineQuery } from "~/lib/defineQuery" +import { parseHtml } from "~/lib/parse-html" import { translate } from "~/lib/translate" -import type { FlatEntryModel } from "~/store/entry" +import { getEntry } from "~/store/entry" export const ai = { translation: ({ - entry, + entryId, view, language, extraFields, part, }: { - entry?: FlatEntryModel | null - view?: number + entryId?: string | null + view?: number | null language?: SupportedLanguages extraFields?: string[] part?: string }) => - defineQuery(["translation", entry?.entries.id, view, language, extraFields, part], () => - translate({ entry, view, language, extraFields, part }), + defineQuery(["translation", entryId, view, language, extraFields, part], () => + translate({ entryId, view, language, extraFields, part }), ), summary: ({ entryId, @@ -32,6 +34,19 @@ export const ai = { target?: "content" | "readabilityContent" }) => defineQuery(["summary", entryId, language, target], async () => { + const content = + target === "readabilityContent" + ? getReadabilityContent()[entryId]?.content + : getEntry(entryId)?.entries.content + if (!content) { + return null + } + + const text = parseHtml(content).toText() + if (text.length < 100) { + return null + } + const res = await apiClient.ai.summary.$get({ query: { id: entryId, diff --git a/apps/desktop/layer/renderer/src/queries/auth.ts b/apps/desktop/layer/renderer/src/queries/auth.ts index d687eb80a4..6e334100ff 100644 --- a/apps/desktop/layer/renderer/src/queries/auth.ts +++ b/apps/desktop/layer/renderer/src/queries/auth.ts @@ -6,7 +6,7 @@ import { setWhoami } from "~/atoms/user" import { QUERY_PERSIST_KEY } from "~/constants" import { useAuthQuery } from "~/hooks/common" import { getAccountInfo, getSession, signOut as signOutFn } from "~/lib/auth" -import { tipcClient } from "~/lib/client" +import { ipcServices } from "~/lib/client" import { defineQuery } from "~/lib/defineQuery" import { clearLocalPersistStoreData } from "~/store/utils/clear" @@ -72,7 +72,7 @@ export const useSession = (options?: { enabled?: boolean }) => { } export const handleSessionChanges = () => { - tipcClient?.sessionChanged() + ipcServices?.auth.sessionChanged() window.location.reload() } @@ -87,7 +87,7 @@ export const signOut = async () => { // Clear local storage clearStorage() // Sign out - await tipcClient?.signOut() + await ipcServices?.auth.signOut() await signOutFn() window.location.reload() } diff --git a/apps/desktop/layer/renderer/src/queries/discover.ts b/apps/desktop/layer/renderer/src/queries/discover.ts index 0ee0f77a96..13ea96c921 100644 --- a/apps/desktop/layer/renderer/src/queries/discover.ts +++ b/apps/desktop/layer/renderer/src/queries/discover.ts @@ -39,9 +39,13 @@ export const discover = { }) return res.data }), - rsshubAnalytics: () => - defineQuery(["discover", "rsshub", "analytics"], async () => { - const res = await apiClient.discover["rsshub-analytics"].$get() + rsshubAnalytics: ({ lang }: { lang?: string }) => + defineQuery(["discover", "rsshub", "analytics", lang], async () => { + const res = await apiClient.discover["rsshub-analytics"].$get({ + query: { + ...(lang !== "all" && { lang }), + }, + }) return res.data }), } diff --git a/apps/desktop/layer/renderer/src/store/action/hooks.ts b/apps/desktop/layer/renderer/src/store/action/hooks.ts deleted file mode 100644 index 9a53dd63c6..0000000000 --- a/apps/desktop/layer/renderer/src/store/action/hooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ActionModel } from "@follow/models/types" - -import { useActionStore } from "./store" - -export function useActions(): ActionModel[] -export function useActions(selector: (actions: ActionModel[]) => T): T -export function useActions(selector?: (actions: ActionModel[]) => T) { - return useActionStore((state) => (selector ? selector(state.actions) : state.actions)) -} - -export function useActionByIndex(index: number): ActionModel -export function useActionByIndex(index: number, selector: (action: ActionModel) => T): T -export function useActionByIndex(index: number, selector?: (action: ActionModel) => T) { - return useActionStore((state) => - selector ? selector(state.actions[index]!) : state.actions[index], - ) -} - -export function useIsActionDataDirty() { - return useActionStore((state) => state.isDirty) -} diff --git a/apps/desktop/layer/renderer/src/store/action/index.ts b/apps/desktop/layer/renderer/src/store/action/index.ts deleted file mode 100644 index a92fe14dc6..0000000000 --- a/apps/desktop/layer/renderer/src/store/action/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./hooks" -export * from "./store" -export * from "./types" diff --git a/apps/desktop/layer/renderer/src/store/action/store.ts b/apps/desktop/layer/renderer/src/store/action/store.ts deleted file mode 100644 index 71a53bb593..0000000000 --- a/apps/desktop/layer/renderer/src/store/action/store.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ActionModel, ActionsResponse } from "@follow/models/types" - -import { apiClient } from "~/lib/api-fetch" - -import { createImmerSetter, createZustandStore } from "../utils/helper" -import type { ActionState } from "./types" - -export const useActionStore = createZustandStore("action")(() => ({ - actions: [], - isDirty: false, -})) - -const set = createImmerSetter(useActionStore) -const get = useActionStore.getState - -class ActionActionStatic { - init(actions: ActionModel[]) { - set((state) => { - state.actions = actions - }) - } - - insertNewEmptyAction(name: string) { - set((state) => { - state.actions.push({ - name, - condition: [], - result: {}, - index: state.actions.length, - }) - state.isDirty = true - }) - } - - removeByIndex(index: number) { - set((state) => { - state.actions.splice(index, 1) - state.isDirty = true - }) - } - - updateByIndex(index: number, update: (action: ActionModel) => void) { - set((state) => { - update(state.actions[index]!) - state.isDirty = true - }) - } - - async fillRemoteActions() { - if (get().actions.length > 0) { - return get().actions - } - - const res = await apiClient.actions.$get() - const rules = (res.data?.rules?.map((rule) => { - const { condition } = rule - // fix old data - const finalCondition = - condition.length === 0 || Array.isArray(condition[0]) ? condition : [condition] - - return { - ...rule, - condition: finalCondition as any, - } - }) || []) as ActionModel[] - this.init(rules) - return rules - } - - async updateRemoteActions() { - if (!get().isDirty) { - return - } - - set((state) => { - state.actions.forEach((action) => { - action.condition = action.condition - .map((condition) => { - return condition.filter((c) => c.field && c.operator && c.value) - }) - .filter((c) => c.length > 0) - action.result.rewriteRules = action.result.rewriteRules?.filter((r) => r.from && r.to) - action.result.blockRules = action.result.blockRules?.filter( - (r) => r.field && r.operator && r.value, - ) - action.result.webhooks = action.result.webhooks?.filter((w) => w) - }) - }) - - await apiClient.actions.$put({ - json: { - rules: get().actions as ActionsResponse, - }, - }) - set((state) => { - state.isDirty = false - }) - } -} - -export const actionActions = new ActionActionStatic() diff --git a/apps/desktop/layer/renderer/src/store/action/types.ts b/apps/desktop/layer/renderer/src/store/action/types.ts deleted file mode 100644 index eb3d0d530b..0000000000 --- a/apps/desktop/layer/renderer/src/store/action/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ActionModel } from "@follow/models/types" - -export interface ActionState { - actions: ActionModel[] - isDirty: boolean -} diff --git a/apps/desktop/layer/renderer/src/store/ai/hook.ts b/apps/desktop/layer/renderer/src/store/ai/hook.ts index fd77e97eba..acb2956389 100644 --- a/apps/desktop/layer/renderer/src/store/ai/hook.ts +++ b/apps/desktop/layer/renderer/src/store/ai/hook.ts @@ -5,24 +5,28 @@ import { useActionLanguage } from "~/atoms/settings/general" import { useAuthQuery } from "~/hooks/common/useBizQuery" import { Queries } from "~/queries" -import type { FlatEntryModel } from "../entry/types" +import { useEntry } from "../entry" export function useEntryTranslation({ - entry, + entryId, extraFields, }: { - entry: FlatEntryModel | null + entryId?: string extraFields?: string[] }) { + const entry = useEntry(entryId, (state) => ({ + view: state.view, + translation: state.settings?.translation, + })) const actionLanguage = useActionLanguage() - const showAITranslationFinal = useShowAITranslation(entry) - const showAITranslationAuto = useShowAITranslationAuto(entry) + const showAITranslationFinal = useShowAITranslation(!!entry?.translation) + const showAITranslationAuto = useShowAITranslationAuto(!!entry?.translation) const showAITranslation = !extraFields || extraFields.length === 0 ? showAITranslationAuto : showAITranslationFinal const res = useAuthQuery( Queries.ai.translation({ - entry, + entryId, view: entry?.view, language: actionLanguage, extraFields, diff --git a/apps/desktop/layer/renderer/src/store/entry/helper.ts b/apps/desktop/layer/renderer/src/store/entry/helper.ts index 4af86360e5..88af650cb9 100644 --- a/apps/desktop/layer/renderer/src/store/entry/helper.ts +++ b/apps/desktop/layer/renderer/src/store/entry/helper.ts @@ -7,6 +7,20 @@ import { useSubscriptionStore } from "../subscription" import { useEntryStore } from "./store" import type { EntryFilter } from "./types" +export const getSortedEntryIds = (entriesIds: string[]) => { + const state = useEntryStore.getState() + const idsSorted = entriesIds.sort((a, b) => { + const entryA = state.flatMapEntries[a] + const entryB = state.flatMapEntries[b] + if (!entryA || !entryB) return 0 + return ( + new Date(entryB.entries.publishedAt).getTime() - + new Date(entryA.entries.publishedAt).getTime() + ) + }) + return idsSorted +} + export const getFilteredFeedIds = (feedIds: string[], filter?: EntryFilter) => { const state = useEntryStore.getState() const ids = [] as string[] @@ -24,9 +38,9 @@ export const getFilteredFeedIds = (feedIds: string[], filter?: EntryFilter) => { result.push(entryId) } } - return result + return getSortedEntryIds(result) } - return ids + return getSortedEntryIds(ids) } const unread = create({ diff --git a/apps/desktop/layer/renderer/src/store/entry/hooks.ts b/apps/desktop/layer/renderer/src/store/entry/hooks.ts index da22cbfe29..f0ead79bae 100644 --- a/apps/desktop/layer/renderer/src/store/entry/hooks.ts +++ b/apps/desktop/layer/renderer/src/store/entry/hooks.ts @@ -12,7 +12,7 @@ import type { EntryFilter, FlatEntryModel } from "./types" export const useEntry = ( entryId: Nullable, - selector?: (state: FlatEntryModel) => T, + selector: (state: FlatEntryModel) => T, ): T | null => useEntryStore( useCallback( @@ -22,7 +22,7 @@ export const useEntry = ( if (!data) return null - return selector ? selector(data) : (data as T) + return selector(data) }, [entryId, selector], ), diff --git a/apps/desktop/layer/renderer/src/store/subscription/hooks.ts b/apps/desktop/layer/renderer/src/store/subscription/hooks.ts index 9db7d079cb..634c127061 100644 --- a/apps/desktop/layer/renderer/src/store/subscription/hooks.ts +++ b/apps/desktop/layer/renderer/src/store/subscription/hooks.ts @@ -1,10 +1,11 @@ -import { FeedViewType, viewList } from "@follow/constants" +import type { FeedViewType } from "@follow/constants" import { useCallback, useMemo } from "react" import { useGeneralSettingSelector } from "~/atoms/settings/general" import { useFeedStore } from "../feed" import { + categoriesSelector, categoryOpenStateByViewSelector, feedIdByViewSelector, feedSubscriptionCountSelector, @@ -15,7 +16,10 @@ import { subscriptionByFeedIdSelector, subscriptionByViewSelector, subscriptionCategoryExistSelector, + subscriptionInboxSelector, + subscriptionListSelector, subscriptionsByFeedIsdSelector, + viewWithSubscriptionSelector, } from "./selector" import { useSubscriptionStore } from "./store" @@ -34,26 +38,9 @@ export const useSubscriptionByView = (view: FeedViewType) => useCallback((state) => subscriptionByViewSelector(view)(state) || [], [view]), ) -export const useViewWithSubscription = () => - useSubscriptionStore( - useCallback((state) => { - return viewList.filter((view) => { - if ( - view === FeedViewType.Articles || - view === FeedViewType.SocialMedia || - view === FeedViewType.Pictures || - view === FeedViewType.Videos - ) { - return true - } else { - return state.feedIdByView[view].length > 0 - } - }) - }, []), - ) +export const useViewWithSubscription = () => useSubscriptionStore(viewWithSubscriptionSelector) -export const useCategories = () => - useSubscriptionStore(useCallback((state) => state.categories || [], [])) +export const useCategories = () => useSubscriptionStore(categoriesSelector) export const useCategoriesByView = (view: FeedViewType) => useSubscriptionStore( @@ -129,21 +116,11 @@ export const useAllFeeds = () => { } export const useAllLists = () => { - return useSubscriptionStore( - useCallback( - (store) => Object.values(store.data).filter((subscription) => subscription.listId), - [], - ), - ) + return useSubscriptionStore(subscriptionListSelector) } export const useAllInboxes = () => { - return useSubscriptionStore( - useCallback( - (store) => Object.values(store.data).filter((subscription) => subscription.inboxId), - [], - ), - ) + return useSubscriptionStore(subscriptionInboxSelector) } export const useFeedsGroupedData = (view: FeedViewType) => { diff --git a/apps/desktop/layer/renderer/src/store/subscription/selector.ts b/apps/desktop/layer/renderer/src/store/subscription/selector.ts index 2d32432038..1bfbc7ee7b 100644 --- a/apps/desktop/layer/renderer/src/store/subscription/selector.ts +++ b/apps/desktop/layer/renderer/src/store/subscription/selector.ts @@ -1,4 +1,4 @@ -import type { FeedViewType } from "@follow/constants" +import { FeedViewType, viewList } from "@follow/constants" import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "~/constants" @@ -56,3 +56,26 @@ export const listSubscriptionCountSelector = (state: State) => state.listIds.siz export const inboxSubscriptionCountSelector = (state: State) => state.inboxIds.size export const isSubscribedSelector = (feedId: string) => (state: State) => !!state.data[feedId] + +export const subscriptionListSelector = (state: State) => + Object.values(state.data).filter((subscription) => subscription.listId) + +export const subscriptionInboxSelector = (store: State) => + Object.values(store.data).filter((subscription) => subscription.inboxId) + +export const viewWithSubscriptionSelector = (state: State) => { + return viewList.filter((view) => { + if ( + view === FeedViewType.Articles || + view === FeedViewType.SocialMedia || + view === FeedViewType.Pictures || + view === FeedViewType.Videos + ) { + return true + } else { + return state.feedIdByView[view].length > 0 + } + }) +} + +export const categoriesSelector = (state: State) => state.categories || [] diff --git a/apps/desktop/layer/renderer/src/styles/additional.css b/apps/desktop/layer/renderer/src/styles/additional.css index 4dbeee4a90..9e3e678587 100644 --- a/apps/desktop/layer/renderer/src/styles/additional.css +++ b/apps/desktop/layer/renderer/src/styles/additional.css @@ -88,12 +88,3 @@ background: linear-gradient(to right, #f2994a, #f2c94c); } } - -.grecaptcha-badge { - visibility: hidden; -} - -iframe[src*="www.recaptcha.net"] { - pointer-events: auto !important; - -webkit-app-region: no-drag !important; -} diff --git a/apps/desktop/layer/renderer/src/tipc.ts b/apps/desktop/layer/renderer/src/tipc.ts deleted file mode 100644 index 79ecc9c51c..0000000000 --- a/apps/desktop/layer/renderer/src/tipc.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createEventHandlers } from "@egoist/tipc/renderer" -import type { RendererHandlers } from "@follow/electron-main" - -export const handlers = window.electron - ? createEventHandlers({ - on: (channel, callback) => { - if (!window.electron) return () => {} - const remover = window.electron.ipcRenderer.on(channel, callback) - return () => { - remover() - } - }, - - send: window.electron.ipcRenderer.send, - }) - : null diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c75d847a97..4cbcdba56e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "0.4.9", + "version": "0.5.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -34,9 +34,8 @@ "update:main-hash": "tsx plugins/vite/generate-main-hash.ts" }, "dependencies": { - "cookie-es": "2.0.0", - "react-google-recaptcha": "3.1.0", - "react-google-recaptcha-v3": "1.10.1" + "@hcaptcha/react-hcaptcha": "1.12.0", + "cookie-es": "2.0.0" }, "devDependencies": { "@clack/prompts": "0.10.1", @@ -58,42 +57,42 @@ "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", "@pengx17/electron-forge-maker-appimage": "1.2.1", - "@sentry/vite-plugin": "3.3.1", + "@sentry/vite-plugin": "3.5.0", "@types/html-minifier-terser": "7.0.2", "@types/js-yaml": "4.0.9", - "@types/react-google-recaptcha": "2.1.9", "@vitejs/plugin-legacy": "6.1.1", - "@vitejs/plugin-react": "4.4.1", + "@vitejs/plugin-react": "4.5.0", "async-es": "3.2.6", "autoprefixer": "10.4.21", "bufferutil": "4.0.9", - "code-inspector-plugin": "0.20.10", - "cssnano": "7.0.6", + "code-inspector-plugin": "0.20.11", + "cssnano": "7.0.7", "dotenv": "16.5.0", - "drizzle-orm": "0.42.0", + "drizzle-orm": "0.43.1", "electron": "35.1.5", "electron-devtools-installer": "4.0.0", "electron-packager-languages": "0.6.0", "electron-vite": "3.1.0", - "es-toolkit": "1.36.0", - "fake-indexeddb": "6.0.0", - "happy-dom": "17.4.4", - "hono": "4.7.7", + "es-toolkit": "1.38.0", + "fake-indexeddb": "6.0.1", + "happy-dom": "17.5.6", + "hono": "4.7.10", "html-minifier-terser": "7.2.0", "js-yaml": "4.1.0", "nbump": "2.1.2", "react": "19.0.0", "react-dom": "19.0.0", + "tailwindcss-content-visibility": "1.0.2", "tailwindcss-multi": "0.4.6", "tar": "7.4.3", - "tsup": "8.4.0", - "unplugin-ast": "0.14.6", + "tsup": "8.5.0", + "unplugin-ast": "0.15.0", "utf-8-validate": "6.0.5", - "vite-bundle-analyzer": "0.19.0", + "vite-bundle-analyzer": "0.22.0", "vite-plugin-mkcert": "1.17.8", "vite-plugin-pwa": "1.0.0", "vite-tsconfig-paths": "5.1.4" }, "productName": "Folo", - "mainHash": "c1310ea7b1278a253bb9f34f38d76e16d3ffd66d910a9af2a071834e789ee180" + "mainHash": "c4275ef4fd948d72b930953bd4d4b3a45bb37a20eeab354f78b86b6eca1a19d6" } diff --git a/apps/desktop/tailwind.config.ts b/apps/desktop/tailwind.config.ts index 0599bf8e96..20cc134b95 100644 --- a/apps/desktop/tailwind.config.ts +++ b/apps/desktop/tailwind.config.ts @@ -100,6 +100,7 @@ export default extendConfig({ addVariant("windows", ":where(html[data-os='Windows']) &") }), require("tailwindcss-multi"), + require("tailwindcss-content-visibility"), plugin(({ addUtilities, matchUtilities, theme }) => { addUtilities({ ".safe-inset-top": { diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js index 004c8313f5..92bb2ed097 100644 --- a/apps/mobile/babel.config.js +++ b/apps/mobile/babel.config.js @@ -5,24 +5,6 @@ module.exports = function (api) { ["babel-preset-expo", { jsxImportSource: "nativewind", unstable_transformImportMeta: true }], "nativewind/babel", ], - plugins: [ - ["inline-import", { extensions: [".sql"] }], - [ - "module-resolver", - { - root: ["./"], - alias: { - "es-toolkit/compat": "../../node_modules/es-toolkit/dist/compat/index.js", - "es-toolkit": "../../node_modules/es-toolkit/dist/index.js", - "better-auth/react": "../../node_modules/better-auth/dist/client/react/index.cjs", - "better-auth/client/plugins": - "../../node_modules/better-auth/dist/client/plugins/index.cjs", - "@better-auth/expo/client": "../../node_modules/@better-auth/expo/dist/client.cjs", - }, - extensions: [".js", ".jsx", ".ts", ".tsx"], - }, - ], - "react-native-reanimated/plugin", - ], + plugins: [["inline-import", { extensions: [".sql"] }], "react-native-reanimated/plugin"], } } diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index ae283bef60..b4934a5820 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -17,7 +17,6 @@ config.resolver.nodeModulesPaths = [ path.resolve(__dirname, "./node_modules"), path.resolve(__dirname, "../../node_modules"), ] -config.resolver.unstable_enablePackageExports = false config.resolver.extraNodeModules = { ...config.resolver.extraNodeModules, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index defc71d6dc..f30104bca1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,7 +6,6 @@ "scripts": { "android": "expo run:android", "bump": "vv", - "db:generate": "drizzle-kit generate", "dev": "npm run start", "eas-build-post-install": "rm -rf $TMPDIR/metro-cache", "eas-build-pre-install": "command -v pod >/dev/null 2>&1 && pod repo update || echo 'CocoaPods not found, skipping pod repo update'", @@ -23,10 +22,12 @@ "@expo/react-native-action-sheet": "4.1.1", "@follow/components": "workspace:*", "@follow/constants": "workspace:*", + "@follow/database": "workspace:*", "@follow/hooks": "workspace:*", "@follow/legal": "workspace:*", "@follow/models": "workspace:*", "@follow/shared": "workspace:*", + "@follow/store": "workspace:*", "@follow/tracker": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", @@ -43,15 +44,15 @@ "@react-native-menu/menu": "1.2.3", "@react-native-picker/picker": "2.11.0", "@shopify/flash-list": "1.7.6", - "@tanstack/query-sync-storage-persister": "5.74.7", - "@tanstack/react-query": "5.74.7", - "@tanstack/react-query-persist-client": "5.74.7", + "@tanstack/query-sync-storage-persister": "5.77.2", + "@tanstack/react-query": "5.77.2", + "@tanstack/react-query-persist-client": "5.77.2", "@types/qrcode": "1.5.5", - "better-auth": "1.2.7", + "better-auth": "1.2.8", "camelcase-keys": "9.1.3", "dayjs": "1.11.13", - "dnum": "2.14.0", - "es-toolkit": "1.36.0", + "dnum": "2.15.0", + "es-toolkit": "1.38.0", "expo": "53.0.4", "expo-apple-authentication": "7.2.4", "expo-application": "6.1.4", @@ -85,10 +86,10 @@ "expo-video": "2.1.8", "expo-web-browser": "14.1.6", "franc-min": "6.2.0", - "hono": "4.7.7", - "i18next": "25.0.1", + "hono": "4.7.10", + "i18next": "25.2.1", "immer": "10.1.1", - "jotai": "2.12.3", + "jotai": "2.12.5", "lru-cache": "11.1.0", "nanoid": "5.1.5", "nativewind": "4.1.23", @@ -97,8 +98,8 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-error-boundary": "5.0.0", - "react-hook-form": "7.56.1", - "react-i18next": "15.5.1", + "react-hook-form": "7.56.4", + "react-i18next": "15.5.2", "react-native": "0.79.1", "react-native-awesome-slider": "2.9.0", "react-native-bouncy-checkbox": "4.1.2", @@ -121,23 +122,21 @@ "react-native-volume-manager": "2.0.8", "react-native-web": "0.20.0", "react-native-webview": "13.13.5", - "shiki": "3.3.0", + "shiki": "3.4.2", "tailwindcss": "3.4.17", "use-sync-external-store": "1.5.0", "usehooks-ts": "3.1.1", "zeego": "3.0.6", - "zod": "3.24.3", - "zustand": "5.0.3" + "zod": "3.25.32", + "zustand": "5.0.5" }, "devDependencies": { - "@babel/core": "7.26.10", + "@babel/core": "7.27.3", "@follow/configs": "workspace:*", - "@types/react": "19.1.3", + "@types/react": "19.1.6", "babel-plugin-inline-import": "3.0.0", - "babel-plugin-module-resolver": "5.0.2", - "drizzle-kit": "0.31.0", - "eas-cli": "16.3.3", - "expo-drizzle-studio-plugin": "0.1.2", + "eas-cli": "16.6.2", + "expo-sqlite-devtools": "0.0.2", "nbump": "2.1.2", "postcss": "8.5.3", "sf-symbols-typescript": "2.1.0", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 38fa0bec3e..79d7923995 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,3 +1,5 @@ +import { usePrefetchActions } from "@follow/store/action/hooks" +import { usePrefetchSessionUser } from "@follow/store/user/hooks" import { StatusBar } from "expo-status-bar" import { View } from "react-native" import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated" @@ -6,11 +8,10 @@ import { useSheet } from "react-native-sheet-transitions" import { useBackHandler } from "./hooks/useBackHandler" import { useIntentHandler } from "./hooks/useIntentHandler" +import { useMessaging, useUpdateMessagingToken } from "./hooks/useMessaging" +import { useOnboarding } from "./hooks/useOnboarding" +import { useUnreadCountBadge } from "./hooks/useUnreadCountBadge" import { DebugButton, EnvProfileIndicator } from "./modules/debug" -import { usePrefetchActions } from "./store/action/hooks" -import { useMessaging, useUpdateMessagingToken } from "./store/messaging/hooks" -import { useUnreadCountBadge } from "./store/unread/hooks" -import { useOnboarding, usePrefetchSessionUser } from "./store/user/hooks" export function App({ children }: { children: React.ReactNode }) { useIntentHandler() diff --git a/apps/mobile/src/api/trending.ts b/apps/mobile/src/api/trending.ts index 59db4e0ad7..89f76687b8 100644 --- a/apps/mobile/src/api/trending.ts +++ b/apps/mobile/src/api/trending.ts @@ -1,4 +1,4 @@ -import type { Models } from "@follow/models/src" +import type { Models } from "@follow/models" import camelcaseKeys from "camelcase-keys" import { apiFetch } from "../lib/api-fetch" diff --git a/apps/mobile/src/atoms/server-configs.ts b/apps/mobile/src/atoms/server-configs.ts index bd4b358e28..be21a1a8cb 100644 --- a/apps/mobile/src/atoms/server-configs.ts +++ b/apps/mobile/src/atoms/server-configs.ts @@ -1,5 +1,5 @@ -import type { ServerConfigs } from "@follow/models/src/types" -import { createAtomHooks } from "@follow/utils/src/jotai" +import type { ServerConfigs } from "@follow/models/types" +import { createAtomHooks } from "@follow/utils/jotai" import { atom } from "jotai" export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks( diff --git a/apps/mobile/src/atoms/settings/general.ts b/apps/mobile/src/atoms/settings/general.ts index 522de8c415..f4a6be281a 100644 --- a/apps/mobile/src/atoms/settings/general.ts +++ b/apps/mobile/src/atoms/settings/general.ts @@ -1,5 +1,7 @@ -import { defaultGeneralSettings } from "@follow/shared/src/settings/defaults" -import type { GeneralSettings } from "@follow/shared/src/settings/interface" +import { defaultGeneralSettings } from "@follow/shared/settings/defaults" +import type { GeneralSettings } from "@follow/shared/settings/interface" +import type { FetchEntriesPropsSettings } from "@follow/store/entry/types" +import { useMemo } from "react" import { getDeviceLanguage } from "@/src/lib/i18n" import type { SupportedLanguages } from "@/src/lib/language" @@ -51,3 +53,22 @@ export function useHideAllReadSubscriptions() { const unreadOnly = useGeneralSettingKey("unreadOnly") return hideAllReadSubscriptions && unreadOnly } + +export function getHideAllReadSubscriptions() { + const { hideAllReadSubscriptions, unreadOnly } = getGeneralSettings() + return hideAllReadSubscriptions && unreadOnly +} + +export function useFetchEntriesSettings(): FetchEntriesPropsSettings { + const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey( + "hidePrivateSubscriptionsInTimeline", + ) + const unreadOnly = useGeneralSettingKey("unreadOnly") + return useMemo( + () => ({ + hidePrivateSubscriptionsInTimeline, + unreadOnly, + }), + [hidePrivateSubscriptionsInTimeline, unreadOnly], + ) +} diff --git a/apps/mobile/src/atoms/settings/internal/helper.ts b/apps/mobile/src/atoms/settings/internal/helper.ts index 0548b89535..7254c263fd 100644 --- a/apps/mobile/src/atoms/settings/internal/helper.ts +++ b/apps/mobile/src/atoms/settings/internal/helper.ts @@ -1,6 +1,6 @@ import { useRefValue } from "@follow/hooks" import { createAtomHooks } from "@follow/utils" -import { EventBus } from "@follow/utils/src/event-bus" +import { EventBus } from "@follow/utils/event-bus" import type { SetStateAction, WritableAtom } from "jotai" import { atom as jotaiAtom, useAtomValue } from "jotai" import { atomWithStorage, selectAtom } from "jotai/utils" diff --git a/apps/mobile/src/atoms/settings/ui.ts b/apps/mobile/src/atoms/settings/ui.ts index d7b248ebae..dbbb4d5227 100644 --- a/apps/mobile/src/atoms/settings/ui.ts +++ b/apps/mobile/src/atoms/settings/ui.ts @@ -1,5 +1,5 @@ -import { defaultUISettings } from "@follow/shared/src/settings/defaults" -import type { UISettings } from "@follow/shared/src/settings/interface" +import { defaultUISettings } from "@follow/shared/settings/defaults" +import type { UISettings } from "@follow/shared/settings/interface" import { getDeviceLanguage } from "@/src/lib/i18n" diff --git a/apps/mobile/src/components/common/CopyButton.tsx b/apps/mobile/src/components/common/CopyButton.tsx index c0ba7f94ef..fa77ec9fc1 100644 --- a/apps/mobile/src/components/common/CopyButton.tsx +++ b/apps/mobile/src/components/common/CopyButton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@follow/utils/src/utils" +import { cn } from "@follow/utils/utils" import { useRef } from "react" import { Pressable } from "react-native" import Animated, { diff --git a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx index 44ae26c341..e8072214e2 100644 --- a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx +++ b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx @@ -1,5 +1,5 @@ import { cn } from "@follow/utils" -import { EventBus } from "@follow/utils/src/event-bus" +import { EventBus } from "@follow/utils/event-bus" import type { FC, PropsWithChildren, ReactNode } from "react" import { createElement, diff --git a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx index 2fe31eb4f3..ceea9f2ed6 100644 --- a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx @@ -114,6 +114,7 @@ export const SafeNavigationScrollView = ({ onContentSizeChange={useTypeScriptHappyCallback( (w, h) => { screenCtxValue.scrollViewContentHeight.value = h + checkScrollToBottom() }, [screenCtxValue.scrollViewContentHeight], )} diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index 55c5c7ad98..a1d3c5c5a4 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -1,3 +1,6 @@ +import { useEntry } from "@follow/store/entry/hooks" +import type { EntryModel } from "@follow/store/entry/types" +import { useEntryTranslation } from "@follow/store/translation/hooks" import { clsx } from "@follow/utils" import { Portal } from "@gorhom/portal" import { useAtom } from "jotai" @@ -5,9 +8,9 @@ import * as React from "react" import { useEffect } from "react" import { TouchableOpacity, View } from "react-native" +import { useActionLanguage } from "@/src/atoms/settings/general" import { useUISettingKey } from "@/src/atoms/settings/ui" import { BugCuteReIcon } from "@/src/icons/bug_cute_re" -import type { EntryModel, EntryWithTranslation } from "@/src/store/entry/types" import { PlatformActivityIndicator } from "../../ui/loading/PlatformActivityIndicator" import { sharedWebViewHeightAtom } from "./atom" @@ -16,7 +19,7 @@ import { prepareEntryRenderWebView, SharedWebViewModule } from "./index" import { NativeWebView } from "./native-webview" type EntryContentWebViewProps = { - entry: EntryWithTranslation + entryId: string noMedia?: boolean showReadability?: boolean showTranslation?: boolean @@ -28,7 +31,8 @@ const setCodeTheme = (light: string, dark: string) => { ) } -const setWebViewEntry = (entry: EntryModel) => { +const setWebViewEntry = (entry?: EntryModel | null) => { + if (!entry) return SharedWebViewModule.evaluateJavaScript( `setEntry(JSON.parse(${JSON.stringify(JSON.stringify(entry))}))`, ) @@ -49,7 +53,10 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { const codeThemeLight = useUISettingKey("codeHighlightThemeLight") const codeThemeDark = useUISettingKey("codeHighlightThemeDark") const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle") - const { entry, noMedia, showReadability, showTranslation } = props + const { entryId, noMedia, showReadability, showTranslation } = props + const entry = useEntry(entryId, (state) => state) + const language = useActionLanguage() + const translation = useEntryTranslation(entryId, language) const [mode, setMode] = React.useState<"normal" | "debug">("normal") @@ -66,17 +73,25 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { }, [codeThemeLight, codeThemeDark, mode]) const entryInWebview = React.useMemo(() => { - const entryContent = showReadability ? entry.readabilityContent : entry.content + if (!entry) return null + + const entryContent = showReadability ? entry?.readabilityContent : entry?.content const translatedContent = showReadability - ? entry.translation?.readabilityContent - : entry.translation?.content + ? translation?.readabilityContent + : translation?.content const content = showTranslation ? translatedContent || entryContent : entryContent return { ...entry, content, } - }, [entry, showReadability, showTranslation]) + }, [ + entry, + showReadability, + showTranslation, + translation?.content, + translation?.readabilityContent, + ]) useEffect(() => { setWebViewEntry(entryInWebview) @@ -105,7 +120,7 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { - {(showReadability ? !entry.readabilityContent : !entry.content) && ( + {(showReadability ? !entry?.readabilityContent : !entry?.content) && ( diff --git a/apps/mobile/src/components/native/webview/native-webview.android.tsx b/apps/mobile/src/components/native/webview/native-webview.android.tsx index e0aaa3c033..6424957a9a 100644 --- a/apps/mobile/src/components/native/webview/native-webview.android.tsx +++ b/apps/mobile/src/components/native/webview/native-webview.android.tsx @@ -50,8 +50,9 @@ export const NativeWebView: React.ComponentType< sharedCookiesEnabled originWhitelist={["*"]} allowUniversalAccessFromFileURLs - startInLoadingState + // startInLoadingState allowsBackForwardNavigationGestures + allowsFullscreenVideo injectedJavaScriptBeforeContentLoaded={atStart} onNavigationStateChange={onNavigationStateChange} onLoadEnd={useCallback(() => { diff --git a/apps/mobile/src/components/ui/avatar/UserAvatar.tsx b/apps/mobile/src/components/ui/avatar/UserAvatar.tsx index 7157a06292..db35c85a4c 100644 --- a/apps/mobile/src/components/ui/avatar/UserAvatar.tsx +++ b/apps/mobile/src/components/ui/avatar/UserAvatar.tsx @@ -1,4 +1,4 @@ -import { cn } from "@follow/utils/src/utils" +import { cn } from "@follow/utils/utils" import { Text, View } from "react-native" import { User4CuteFiIcon } from "@/src/icons/user_4_cute_fi" diff --git a/apps/mobile/src/components/ui/carousel/MediaCarousel.tsx b/apps/mobile/src/components/ui/carousel/MediaCarousel.tsx index c65329a9f9..edf38aad89 100644 --- a/apps/mobile/src/components/ui/carousel/MediaCarousel.tsx +++ b/apps/mobile/src/components/ui/carousel/MediaCarousel.tsx @@ -1,4 +1,5 @@ import type { FeedViewType } from "@follow/constants" +import type { MediaModel } from "@follow/database/schemas/types" import { useEffect, useMemo, useState } from "react" import { ScrollView, View } from "react-native" import Animated, { @@ -9,7 +10,6 @@ import Animated, { } from "react-native-reanimated" import { Galeria } from "@/src/components/ui/image/galeria" -import type { MediaModel } from "@/src/database/schemas/types" import { EntryGridFooter } from "@/src/modules/entry-content/EntryGridFooter" import { Image } from "../image/Image" diff --git a/apps/mobile/src/components/ui/form/Label.tsx b/apps/mobile/src/components/ui/form/Label.tsx index 113272c495..5dada73672 100644 --- a/apps/mobile/src/components/ui/form/Label.tsx +++ b/apps/mobile/src/components/ui/form/Label.tsx @@ -1,4 +1,4 @@ -import { cn } from "@follow/utils/src/utils" +import { cn } from "@follow/utils/utils" import type { FC, PropsWithChildren } from "react" import type { StyleProp, ViewStyle } from "react-native" import { Text, View } from "react-native" diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx index d73955b98d..15197a734c 100644 --- a/apps/mobile/src/components/ui/form/Select.tsx +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -100,11 +100,9 @@ export function Select({ } return ( - + - - - {Trigger} + {Trigger} ) } diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index fce72de47f..c75bb9fe98 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -1,5 +1,5 @@ import { composeEventHandlers } from "@follow/utils" -import { cn } from "@follow/utils/src/utils" +import { cn } from "@follow/utils/utils" import { useEffect, useImperativeHandle, useRef, useState } from "react" import type { StyleProp, TextInputProps, ViewStyle } from "react-native" import { StyleSheet, Text, TextInput, TouchableWithoutFeedback, View } from "react-native" diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 3c4f971b31..b0d2159319 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -4,7 +4,7 @@ import type { FC, PropsWithChildren } from "react" import * as React from "react" import { Fragment } from "react" import type { PressableProps, ViewProps } from "react-native" -import { Pressable, StyleSheet, Text, View } from "react-native" +import { Pressable, StyleSheet, Text, TouchableOpacity, View } from "react-native" import Animated, { FadeIn, FadeOut } from "react-native-reanimated" import type { SFSymbol } from "sf-symbols-typescript" @@ -58,7 +58,7 @@ export const GroupedInsetListCard: FC< {...props} style={[{ marginHorizontal: GROUPED_LIST_MARGIN }, props.style]} className={cn( - "bg-secondary-system-grouped-background flex-1 overflow-hidden rounded-[10px]", + "bg-secondary-system-grouped-background flex overflow-hidden rounded-[10px]", className, )} > @@ -82,7 +82,7 @@ export const GroupedInsetListCard: FC< : NextSeparatorComponent return ( - + {child} {!isLast && (NextSeparatorElement ?? ( @@ -195,10 +195,15 @@ export const GroupedInsetListCell: FC< description?: string children?: React.ReactNode icon?: SFSymbol + onPress?: () => void } & BaseCellClassNames -> = ({ label, description, children, leftClassName, rightClassName, icon }) => { +> = ({ label, description, children, leftClassName, rightClassName, icon, onPress }) => { return ( - + {!!icon && } diff --git a/apps/mobile/src/components/ui/icon/feed-icon.tsx b/apps/mobile/src/components/ui/icon/feed-icon.tsx index 6730f60362..43b1d94b80 100644 --- a/apps/mobile/src/components/ui/icon/feed-icon.tsx +++ b/apps/mobile/src/components/ui/icon/feed-icon.tsx @@ -1,8 +1,8 @@ import type { FeedViewType } from "@follow/constants" +import type { FeedSchema } from "@follow/database/schemas/types" import type { ReactNode } from "react" import { useCallback, useMemo, useState } from "react" -import type { FeedSchema } from "@/src/database/schemas/types" import { getFeedIconSource } from "@/src/lib/image" import type { ImageProps } from "../image/Image" @@ -35,7 +35,7 @@ export function FeedIcon({ fallbackUrl, className, size = 20, - fallback = true, + fallback, fallbackElement, siteUrl, ...props diff --git a/apps/mobile/src/components/ui/image/ImageContextMenu.tsx b/apps/mobile/src/components/ui/image/ImageContextMenu.tsx index 8325cad3e5..0df37b5814 100644 --- a/apps/mobile/src/components/ui/image/ImageContextMenu.tsx +++ b/apps/mobile/src/components/ui/image/ImageContextMenu.tsx @@ -1,23 +1,20 @@ import type { FeedViewType } from "@follow/constants" -import ImageEditor from "@react-native-community/image-editor" +import { useIsEntryStarred } from "@follow/store/collection/hooks" +import { collectionSyncService } from "@follow/store/collection/store" +import { useEntry } from "@follow/store/entry/hooks" +import { unreadSyncService } from "@follow/store/unread/store" import { requireNativeModule } from "expo" -import * as FileSystem from "expo-file-system" -import { saveToLibraryAsync } from "expo-media-library" -import { shareAsync } from "expo-sharing" import type { PropsWithChildren } from "react" import { useRef } from "react" import { useTranslation } from "react-i18next" import type { View } from "react-native" -import { findNodeHandle, Image, Pressable } from "react-native" +import { findNodeHandle, Pressable } from "react-native" import { isIOS } from "@/src/lib/platform" import { toast } from "@/src/lib/toast" -import { useIsEntryStarred } from "@/src/store/collection/hooks" -import { collectionSyncService } from "@/src/store/collection/store" -import { useEntry } from "@/src/store/entry/hooks" -import { unreadSyncService } from "@/src/store/unread/store" import { ContextMenu } from "../context-menu" +import { shareImage, useSaveImageToMediaLibrary } from "./utils" type ImageContextMenuProps = PropsWithChildren<{ imageUrl?: string @@ -38,46 +35,21 @@ const getIOSNativeImageActions = () => { export const ImageContextMenu = ({ imageUrl, entryId, children, view }: ImageContextMenuProps) => { const { t } = useTranslation() - const entry = useEntry(entryId!) + const entry = useEntry(entryId, (state) => ({ + read: state.read, + feedId: state.feedId, + })) const feedId = entry?.feedId const isEntryStarred = useIsEntryStarred(entryId!) const contextMenuTriggerRef = useRef(null) + const saveImageToAlbum = useSaveImageToMediaLibrary() if (!imageUrl || !entry) { return children } - const getImageData = async () => { - const size = await Image.getSize(imageUrl) - const croppedImage = await ImageEditor.cropImage(imageUrl, { - offset: { - x: 0, - y: 0, - }, - size, - format: "png", - includeBase64: true, - }) - - return croppedImage - } - - const createTempFile = async (base64: string) => { - const fileUri = await FileSystem.getInfoAsync(FileSystem.cacheDirectory!) - const filename = `${imageUrl.split("/").pop()}.png` - const filePath = `${fileUri.uri}/${filename}` - await FileSystem.writeAsStringAsync(filePath, base64, { - encoding: FileSystem.EncodingType.Base64, - }) - - return { - filePath, - cleanup: () => FileSystem.deleteAsync(filePath), - } - } - return ( @@ -144,11 +116,7 @@ export const ImageContextMenu = ({ imageUrl, entryId, children, view }: ImageCon } getIOSNativeImageActions().saveImageByHandle(handle) } else { - const croppedImage = await getImageData() - const { filePath, cleanup } = await createTempFile(croppedImage.base64) - await saveToLibraryAsync(filePath) - toast.success("Image saved to library") - cleanup() + saveImageToAlbum(imageUrl) } }} > @@ -170,13 +138,7 @@ export const ImageContextMenu = ({ imageUrl, entryId, children, view }: ImageCon } getIOSNativeImageActions().shareImageByHandle(handle, imageUrl) } else { - const croppedImage = await getImageData() - const { filePath, cleanup } = await createTempFile(croppedImage.base64) - await shareAsync(filePath, { - dialogTitle: "Share Image", - }) - - cleanup() + shareImage({ uri: imageUrl }) } }} > diff --git a/apps/mobile/src/components/ui/image/utils.ts b/apps/mobile/src/components/ui/image/utils.ts index 67bfc96c7e..643f0fbf2f 100644 --- a/apps/mobile/src/components/ui/image/utils.ts +++ b/apps/mobile/src/components/ui/image/utils.ts @@ -1,8 +1,16 @@ -import { createBuildSafeHeaders } from "@follow/utils/src/headers" -import { getImageProxyUrl, IMAGE_PROXY_URL } from "@follow/utils/src/img-proxy" +import { createBuildSafeHeaders } from "@follow/utils/headers" +import { getImageProxyUrl, IMAGE_PROXY_URL } from "@follow/utils/img-proxy" +import ImageEditor from "@react-native-community/image-editor" +import * as FileSystem from "expo-file-system" import type { ImageProps, ImageSource } from "expo-image" +import { saveToLibraryAsync, usePermissions } from "expo-media-library" +import * as Sharing from "expo-sharing" +import { useCallback } from "react" +import { Image } from "react-native" +import { isNative } from "@/src/lib/platform" import { proxyEnv } from "@/src/lib/proxy-env" +import { toast } from "@/src/lib/toast" const buildSafeHeaders = createBuildSafeHeaders(proxyEnv.WEB_URL, [ IMAGE_PROXY_URL, @@ -61,3 +69,128 @@ export const getAllSources = ( return [safeSource, proxiesSafeSource] } + +const getImageData = async (imageUrl: string) => { + const size = await Image.getSize(imageUrl) + const croppedImage = await ImageEditor.cropImage(imageUrl, { + offset: { + x: 0, + y: 0, + }, + size, + format: "png", + includeBase64: true, + }) + + return croppedImage +} + +const createTempFile = async (base64: string, filename: string) => { + if (!FileSystem.cacheDirectory) { + throw new Error("Cache directory is not available") + } + const fileUri = await FileSystem.getInfoAsync(FileSystem.cacheDirectory) + const filePath = `${fileUri.uri}/${filename}` + await FileSystem.writeAsStringAsync(filePath, base64, { + encoding: FileSystem.EncodingType.Base64, + }) + + return { + filePath, + cleanup: () => FileSystem.deleteAsync(filePath), + } +} + +export function extractFilenameFromUrl(uri: string): string { + // Extract actual filename from URL, removing any query parameters + const urlWithoutParams = uri.split(/[?#]/).at(0) + const lastSegment = urlWithoutParams?.split("/").pop() || "image" + return lastSegment +} + +export async function shareImage({ uri }: { uri: string }) { + if (!(await Sharing.isAvailableAsync())) { + // TODO might need to give an error to the user in this case -prf + return + } + const croppedImage = await getImageData(uri) + const filename = `${extractFilenameFromUrl(uri)}.png` + + const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename) + await Sharing.shareAsync(filePath, { + mimeType: "image/png", + dialogTitle: "Share Image", + }) + + cleanup() +} + +export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => { + const croppedImage = await getImageData(uri) + const filename = `${extractFilenameFromUrl(uri)}.png` + const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename) + await saveToLibraryAsync(filePath) + cleanup() +} + +/** + * Same as `saveImageToMediaLibrary`, but also handles permissions and toasts + * + * Ported from https://github.com/bluesky-social/social-app/blob/a0ea634349fd7eac40d72dbd57339f1d6c53a117/src/lib/media/save-image.ts + * + * @example + * ```ts + * const saveImageToAlbum = useSaveImageToMediaLibrary() + * ``` + */ +export function useSaveImageToMediaLibrary() { + const [permissionResponse, requestPermission, getPermission] = usePermissions({ + granularPermissions: ["photo"], + }) + return useCallback( + async (uri: string) => { + if (!isNative) { + throw new Error("useSaveImageToMediaLibrary is native only") + } + + async function save() { + try { + await saveImageToMediaLibrary({ uri }) + toast.success("Image saved to library") + } catch (e: any) { + toast.error(`Failed to save image: ${String(e)}`) + } + } + + const permission = permissionResponse ?? (await getPermission()) + + if (permission.granted) { + await save() + } else { + if (permission.canAskAgain) { + // request again once + const askAgain = await requestPermission() + if (askAgain.granted) { + await save() + } else { + // since we've been explicitly denied, show a toast. + toast.error( + `Images cannot be saved unless permission is granted to access your photo library.`, + { + duration: 5000, + }, + ) + } + } else { + toast.info( + `Permission to access your photo library was denied. Please enable it in your system settings.`, + { + duration: 5000, + }, + ) + } + } + }, + [permissionResponse, requestPermission, getPermission], + ) +} diff --git a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx index be5c0bb14a..d38ab1fc6e 100644 --- a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx +++ b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx @@ -2,8 +2,8 @@ import "@follow/components/assets/colors-media.css" import "@follow/components/assets/tailwind.css" -import type { HtmlProps } from "@follow/components" -import { Html } from "@follow/components" +import type { HtmlProps } from "@follow/components/ui/markdown/html.tsx" +import { Html } from "@follow/components/ui/markdown/html.tsx" import { useEffect } from "react" function useSize(callback: (size: [number, number]) => void) { diff --git a/apps/mobile/src/components/ui/typography/Markdown.tsx b/apps/mobile/src/components/ui/typography/Markdown.tsx deleted file mode 100644 index 2baf6552cb..0000000000 --- a/apps/mobile/src/components/ui/typography/Markdown.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { FC } from "react" -import { useRef } from "react" -import { Linking } from "react-native" -import type { WebView } from "react-native-webview" - -import MarkdownWeb from "./MarkdownWeb" - -export const Markdown: FC<{ - value: string - style?: React.CSSProperties - className?: string - - webViewProps?: import("expo/dom").DOMProps -}> = ({ value, style, className, webViewProps }) => { - const ref = useRef(null) - - return ( - { - const { type, url } = JSON.parse(event.nativeEvent.data) - if (type === "openLinkInModal") { - Linking.openURL(url) - } - }, - injectedJavaScriptBeforeContentLoaded: `window.openLinkInModal = (url) => { - window.ReactNativeWebView.postMessage(JSON.stringify({ - type: "openLinkInModal", - url, - })) - }`, - }} - /> - ) -} diff --git a/apps/mobile/src/components/ui/typography/MarkdownNative.tsx b/apps/mobile/src/components/ui/typography/MarkdownNative.tsx new file mode 100644 index 0000000000..fcec882db4 --- /dev/null +++ b/apps/mobile/src/components/ui/typography/MarkdownNative.tsx @@ -0,0 +1,11 @@ +import { useMemo } from "react" + +import { renderMarkdown } from "@/src/lib/markdown" + +export const MarkdownNative: WebComponent<{ + value: string +}> = ({ value }) => { + return useMemo(() => { + return renderMarkdown(value) + }, [value]) +} diff --git a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx deleted file mode 100644 index 03733fa8ab..0000000000 --- a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use dom" -import "@/src/global.css" - -import { parseMarkdown } from "@follow/components/src/utils/parse-markdown" -import { cn } from "@follow/utils" -import { useMemo } from "react" -import { useDarkMode } from "usehooks-ts" - -import { useCSSInjection } from "@/src/theme/web" - -declare const window: { - openLinkInModal: (url: string) => void -} -/** - * @internal - */ -const MarkdownWeb: WebComponent<{ - value: string - style?: React.CSSProperties - className?: string -}> = ({ value, style, className }) => { - useCSSInjection() - - const { isDarkMode } = useDarkMode() - return ( -
-